From 6d882327a46cfb658ef801c5d961320133d848fd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:04:18 -0700 Subject: [PATCH 01/84] feat(native-chat): a new chat starts at rest; its first message starts the agent agentSession.create writes the record at rest (shared with /clear's founding) and opens its journal; no agent starts. The attach is now only the work-time start, which carries the create's timing span. agentSession.ensure, which no client ever called, is removed. The orchestration worker's preamble starts its agent and reports a start that is still being retried. --- .../claude-api-retry-idle-sweep.test.ts | 5 +- .../codex-provider-retry-idle-sweep.test.ts | 5 +- .../codex-structured-question-order.test.ts | 3 +- ...-agent-session-accept-then-deliver.test.ts | 3 +- ...tured-agent-session-adopted-import.test.ts | 2 +- ...ured-agent-session-append-delivery.test.ts | 3 +- ...red-agent-session-at-rest-commands.test.ts | 4 +- .../structured-agent-session-attach-flow.ts | 13 +- ...ured-agent-session-attach-orchestration.ts | 61 +++----- ...tured-agent-session-attach-test-support.ts | 22 +++ .../structured-agent-session-attach.ts | 13 +- ...-agent-session-claude-compact-stop.test.ts | 4 +- ...-agent-session-claude-echo-working.test.ts | 3 +- ...-agent-session-claude-option-queue.test.ts | 4 +- ...d-agent-session-claude-queued-stop.test.ts | 3 +- ...t-session-claude-stop-ends-session.test.ts | 3 +- ...tured-agent-session-codex-stop-row.test.ts | 3 +- ...d-agent-session-conversation-close.test.ts | 3 +- ...ed-agent-session-conversation-stop.test.ts | 3 +- ...ured-agent-session-crash-mid-start.test.ts | 13 +- ...tured-agent-session-crash-turn-end.test.ts | 4 +- ...structured-agent-session-create-at-rest.ts | 148 ++++++++++++++++++ ...ession-failed-create-owner-verdict.test.ts | 25 +-- ...session-failed-create-sink-release.test.ts | 17 +- ...ctured-agent-session-forget-status.test.ts | 16 +- ...tured-agent-session-grouped-prompt.test.ts | 11 +- ...ed-agent-session-host-test-abandon.test.ts | 5 +- ...uctured-agent-session-host-test-harness.ts | 3 +- .../structured-agent-session-host.test.ts | 48 +++--- .../structured-agent-session-host.ts | 8 +- ...ured-agent-session-late-settlement.test.ts | 3 +- ...t-session-launch-send-after-create.test.ts | 3 +- ...gent-session-legacy-handoff-record.test.ts | 5 +- ...ssion-main-agent-working-agreement.test.ts | 3 +- ...ed-agent-session-option-settlement.test.ts | 4 +- ...ed-agent-session-owed-work-release.test.ts | 4 +- ...gent-session-provider-child-record.test.ts | 3 +- ...red-agent-session-provider-restore.test.ts | 4 +- ...red-agent-session-provider-started.test.ts | 9 +- ...session-queued-message-rig.test-fixture.ts | 3 +- ...tured-agent-session-recovery-exits.test.ts | 21 +-- ...ctured-agent-session-refusal-retry.test.ts | 5 +- ...uctured-agent-session-repeat-press.test.ts | 7 +- .../structured-agent-session-rest-test-rig.ts | 3 +- ...ssion-restart-interruption-test-harness.ts | 4 +- ...session-restart-status-publication.test.ts | 3 +- ...tured-agent-session-rewind-at-rest.test.ts | 7 +- .../structured-agent-session-rewind.test.ts | 46 ++++-- ...red-agent-session-send-preparation.test.ts | 3 +- ...session-send-restarts-failed-start.test.ts | 3 +- ...agent-session-settled-attach-retry.test.ts | 19 ++- ...agent-session-start-failure-writer.test.ts | 3 +- ...red-agent-session-surface-lifetime.test.ts | 7 +- ...sion-view-start-after-failed-start.test.ts | 4 +- ...t-session-wedged-profile-migration.test.ts | 27 ++-- .../structured-conversation-command.test.ts | 8 +- .../runtime/agent-session-at-rest-create.ts | 84 ++++++++++ .../runtime/agent-session-record-founding.ts | 11 +- .../runtime/agent-session-record-store.ts | 11 ++ .../agent-session-reservation-admission.ts | 10 +- ...laude-structured-exit-mid-response.test.ts | 5 +- ...ude-structured-failed-start-resume.test.ts | 10 +- ...e-structured-idle-releases-unknown.test.ts | 13 +- ...e-structured-resumed-start-failure.test.ts | 5 +- ...e-structured-send-held-for-startup.test.ts | 9 +- ...-send-restart-dies-before-dispatch.test.ts | 17 +- ...tured-startup-fault-is-not-an-exit.test.ts | 12 +- ...startup-unanswered-control-request.test.ts | 25 ++- ...structured-agent-session-create-support.ts | 4 +- ...orchestration-structured-worker-session.ts | 31 +++- .../deliver-worker-dispatch-preamble.ts | 13 +- .../structured-agent-session-at-rest.test.ts | 3 +- .../structured-agent-session-create.ts | 15 +- .../structured-agent-session-hold.test.ts | 5 +- .../structured-agent-session-reveal.ts | 2 +- .../rpc/methods/structured-agent-session.ts | 15 +- .../methods/structured-chat-tab-table.test.ts | 4 +- .../runtime-rpc-mobile-method-allowlist.ts | 1 - ...-session-codex-turn-end-settlement.test.ts | 3 +- ...uctured-agent-session-runtime-exit.test.ts | 9 +- ...t-session-runtime-provider-started.test.ts | 19 ++- .../structured-chat-coordinator-mail.test.ts | 7 +- .../structured-child-records-switch.test.ts | 3 +- .../structured-claude-pending-rewind.test.ts | 5 +- ...tructured-codex-child-work-runtime.test.ts | 3 +- .../rpc-params-catalog.generated.ts | 6 +- 86 files changed, 734 insertions(+), 295 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-support.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-create-at-rest.ts create mode 100644 src/main/runtime/agent-session-at-rest-create.ts diff --git a/src/main/claude/claude-api-retry-idle-sweep.test.ts b/src/main/claude/claude-api-retry-idle-sweep.test.ts index bac1da60fe73..1dcd471c247f 100644 --- a/src/main/claude/claude-api-retry-idle-sweep.test.ts +++ b/src/main/claude/claude-api-retry-idle-sweep.test.ts @@ -18,6 +18,7 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' const SWEEP_MS = 5 const RETRY_GAP_MS = 10 * 60_000 @@ -94,7 +95,9 @@ afterEach(async () => { describe('a Claude retrying a refused request', () => { it('keeps the conversation open past the idle window while retry frames arrive', async () => { - expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({ + expect( + await attachForTests(host, { callerKey: 'client-1' }, hostTestAttachParams(null)) + ).toMatchObject({ ok: true }) if (!sink) { diff --git a/src/main/codex/codex-provider-retry-idle-sweep.test.ts b/src/main/codex/codex-provider-retry-idle-sweep.test.ts index b1e8b8b6510c..b8bfdca9cd36 100644 --- a/src/main/codex/codex-provider-retry-idle-sweep.test.ts +++ b/src/main/codex/codex-provider-retry-idle-sweep.test.ts @@ -18,6 +18,7 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { createCodexJournalTranslator } from './codex-structured-journal-translation' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' const SWEEP_MS = 5 const RETRY_GAP_MS = 10 * 60_000 @@ -92,7 +93,9 @@ afterEach(async () => { describe('a Codex reconnecting a dropped stream', () => { it('keeps the conversation open past the idle window while retry frames arrive', async () => { - expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({ + expect( + await attachForTests(host, { callerKey: 'client-1' }, hostTestAttachParams(null)) + ).toMatchObject({ ok: true }) if (!sink) { diff --git a/src/main/codex/codex-structured-question-order.test.ts b/src/main/codex/codex-structured-question-order.test.ts index 069fe4eb048e..1b027bd4742b 100644 --- a/src/main/codex/codex-structured-question-order.test.ts +++ b/src/main/codex/codex-structured-question-order.test.ts @@ -36,6 +36,7 @@ import { structuredQuestionTranscript } from '../../renderer/src/components/native-chat/structured-agent-question-projection' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } @@ -101,7 +102,7 @@ beforeEach(async () => { // Every write lands on its own millisecond, as it does live. now: () => (clock += 1) }) - expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) + expect((await attachForTests(host, CALLER, hostTestAttachParams(null))).ok).toBe(true) const page = await host.history({ sessionId: SESSION, direction: 'tail' }) if (!page.ok) { throw new Error('no history page') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index 2bf7c026fb2f..c262fcfcfcdf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -43,6 +43,7 @@ import { import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } @@ -110,7 +111,7 @@ beforeEach(async () => { })) store = await openTestAgentSessionRecordStore(root) await startHost() - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) }) afterEach(async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index 6bb43fb9d31d..1eab96a21d2b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -244,7 +244,7 @@ describe('adopting a provider conversation on create', () => { new Error('disk write failed') ) const attached = await host - .attach({ callerKey: 'client-1' }, attachParams(transcriptPath)) + .create({ callerKey: 'client-1' }, attachParams(transcriptPath)) .catch(() => null) expect(attached?.ok).not.toBe(true) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts index 7ceac15ff19d..8d5e8bac0782 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts @@ -26,6 +26,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' @@ -143,7 +144,7 @@ beforeEach(async () => { mintSpawnToken: () => `spawn-${generation + 1}`, now: () => NOW }) - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ + await expect(attachForTests(host, CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: true }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts index 063cf84ebd79..d18759810c8e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-at-rest-commands.test.ts @@ -27,6 +27,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const caller = { callerKey: 'desktop' } const CLAUDE_SESSION = '819cf9f8-e43c-4ad7-b50f-54aa158a726a' @@ -128,7 +129,8 @@ beforeEach(async () => { await writeSkill(join(workspace, '.claude', 'skills'), 'review-pr') await writeCommand(join(account, 'commands'), 'deploy.md') await openHost() - const attached = await host.attach( + const attached = await attachForTests( + host, caller, hostTestAttachParams(null, { provider: 'claude', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index c469035c4689..838992305c2e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -34,10 +34,6 @@ import type { StructuredAgentSessionEventSink } from './structured-agent-session import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' import { readAgentSessionHydrationPage } from './agent-session-history-page' import { acquireOwner } from './structured-agent-session-acquisition' -import { - importAdoptedTranscript, - prepareAdoptedTranscript -} from './structured-agent-session-adopted-import' import { withAgentSessionCreatePhase, type AgentSessionCreatePhaseRecorder @@ -95,7 +91,7 @@ export async function performAttach( if (!admitted.ok) { return admitted } - // Ensure/recovery bypass create-intent, so recheck before reserving or spawning. + // An agent start bypasses create-intent, so recheck before reserving or spawning. if (!adapterSupportsCreateIfDeclared(input.adapter, params.location, params.agent)) { return unsupported() } @@ -108,12 +104,6 @@ export async function performAttach( let unsupportedReservationSettlementAttempted = false let replayed = false let providerHistoryWindow: ProviderHistoryWindow | null = null - const preparedTranscript = store.getRecord(sessionId) - ? { ok: true as const, items: null } - : await prepareAdoptedTranscript(params) - if (!preparedTranscript.ok) { - return preparedTranscript - } try { const reserved = await withAgentSessionCreatePhase('reserve_owner', input.recordPhase, () => store.reserveOwner( @@ -230,7 +220,6 @@ export async function performAttach( openConversation: input.openConversation, providerHistoryWindow }) - await importAdoptedTranscript(params, attached, record, preparedTranscript.items) await input.onAttached(attached, acquisitionGeneration, acquiredOwner, providerChildPhase) await store.recordOperationOutcome({ callerKey: input.callerKey, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 9cfdf6a9b87c..233df670024f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -50,12 +50,10 @@ export type StructuredAgentSessionAttachOptions = { } /** - * The attach itself, for a caller already inside the session's serialize. - * - * That is every caller that has to know what the session looks like RIGHT NOW: the delivery loop, - * and an operation that needs the provider. They run their check and this attach in one serialized - * step, so "the session has no child" is still true when - * the attach starts. `attachStructuredAgentSession` is this under `serialize`, for a client. + * The attach itself, for a caller already inside the session's serialize: the delivery loop, and + * an operation that needs the provider. They run their check and this attach in one serialized + * step, so "the session has no child" is still true when the attach starts. It is the only way an + * agent starts; a create starts none (`structured-agent-session-create-at-rest`). */ export function attachStructuredAgentSessionUnderSerialize( context: StructuredAgentSessionAttachContext, @@ -63,36 +61,27 @@ export function attachStructuredAgentSessionUnderSerialize( params: AgentSessionAttachParams, options: StructuredAgentSessionAttachOptions = {} ): Promise> { - return context.tasks.trackAttach(runAttach(context, callerKey, params, options)) -} - -export function attachStructuredAgentSession( - context: StructuredAgentSessionAttachContext, - callerKey: string, - params: AgentSessionAttachParams -): Promise> { - const sessionId = params.envelope.sessionId - // Tracked from enqueue, not from its turn on the queue: a quit drains a queued attach before it - // evicts, so no child is spawned behind the eviction and orphaned. - const run = (recordPhase?: AgentSessionCreatePhaseRecorder) => - context.tasks.trackAttach( - context.serialize(sessionId, () => runAttach(context, callerKey, params, { recordPhase })) - ) - if (params.envelope.expectedRuntimeFence !== null) { - return run() - } - return withAgentSessionSpan(async (span) => { - const startedAtMs = Date.now() - const phases: Parameters[0][] = [] - try { - return await run((timing) => phases.push(timing)) - } finally { - addAgentSessionCreatePhaseAttributes(span, { - totalDurationMs: Math.max(0, Date.now() - startedAtMs), - phases - }) - } - }) + // Tracked from the start: a quit drains it before it evicts, so no child is orphaned behind that. + return context.tasks.trackAttach( + withAgentSessionSpan(async (span) => { + const startedAtMs = Date.now() + const phases: Parameters[0][] = [] + try { + return await runAttach(context, callerKey, params, { + ...options, + recordPhase: (timing) => { + phases.push(timing) + options.recordPhase?.(timing) + } + }) + } finally { + addAgentSessionCreatePhaseAttributes(span, { + totalDurationMs: Math.max(0, Date.now() - startedAtMs), + phases + }) + } + }) + ) } async function runAttach( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-support.ts new file mode 100644 index 000000000000..8c58c2b08acd --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-support.ts @@ -0,0 +1,22 @@ +// For a test that needs a running agent now: the attach an agent start makes, under the session's +// serialize, as the delivery loop runs it. A create starts nothing; its first message does. + +import type { + AgentSessionAttachResult, + AgentSessionMutationResult +} from '../../../shared/agent-session-wire' +import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { attachStructuredAgentSessionUnderSerialize } from './structured-agent-session-attach-orchestration' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' + +export function attachForTests( + host: StructuredAgentSessionHost, + caller: StructuredAgentSessionCaller, + params: AgentSessionAttachParams +): Promise> { + const { attachContext, serialize } = host.collaboratorsForTests() + return serialize(params.envelope.sessionId, () => + attachStructuredAgentSessionUnderSerialize(attachContext(), caller.callerKey, params) + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index 85f99056c3a7..9dd69020054e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -72,8 +72,8 @@ export type AgentSessionAttachParams = { * than starting one: it seeds the handle chain so the adapter resumes instead of creating, and * names the transcript to import so the journal shows the conversation so far. * - * Deliberately separate from `providerHandle`, which `agentSession.ensure` already supplies - * without adopting — presence of a handle must never be what triggers a resume. + * Deliberately separate from `providerHandle`, which an agent start supplies without + * adopting — presence of a handle must never be what triggers a resume. */ adopt?: { providerHandle: Exclude @@ -264,7 +264,7 @@ async function reconcileAgainstProviderHistory(input: { */ const ADOPTED_HANDLE_FENCE = 1 -function adoptedProviderHandleLink( +export function adoptedProviderHandleLink( handle: Exclude, observedAt: number ): AgentSessionProviderHandleLink { @@ -306,13 +306,6 @@ export function reserveRequestFor(input: { : {}), ...(authority.launchArgs ? { launchArgs: authority.launchArgs } : {}), ...(authority.launchEnv ? { launchEnv: authority.launchEnv } : {}), - ...(params.adopt - ? { - // Fence 1 is a new record's first, and the owner probe requires the head link to carry - // the record's current fence. - adoptedHandleLink: adoptedProviderHandleLink(params.adopt.providerHandle, input.now) - } - : {}), expectedFence: params.envelope.expectedRuntimeFence, spawnToken: authority.spawnToken, claimKeyId: authority.claimKeyId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts index 041b6580ea21..52f6be673139 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts @@ -28,6 +28,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } @@ -78,7 +79,8 @@ beforeEach(async () => { mintSpawnToken: () => 'spawn-a', now: () => NOW }) - const attached = await host.attach( + const attached = await attachForTests( + host, CALLER, hostTestAttachParams(null, { provider: 'claude', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts index 1ba4f13e4ea8..018c9ff2d568 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts @@ -27,6 +27,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } @@ -79,7 +80,7 @@ beforeEach(async () => { accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) - expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, params)).toMatchObject({ ok: true }) await adapter.awaitStarted(SESSION) await Promise.all(lifecycle) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts index 6398d96ab0a1..b84eb73c7107 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts @@ -16,6 +16,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-claude' } const CLAUDE_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f61' @@ -91,7 +92,8 @@ beforeEach(async () => { now: () => NOW }) expect( - await host.attach( + await attachForTests( + host, CALLER, hostTestAttachParams(null, { provider: 'claude', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts index 879f83b8a6ca..4aba0b8505f6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts @@ -29,6 +29,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } // As Claude Code 2.1.280 advertises them on a turn's system/init frame. @@ -95,7 +96,7 @@ beforeEach(async () => { accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) - expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, params)).toMatchObject({ ok: true }) await adapter.awaitStarted(SESSION) await Promise.all(lifecycle) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts index ad314376b1a5..1673beeb5034 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-stop-ends-session.test.ts @@ -40,6 +40,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } // As Claude Code 2.1.280 advertises them on a turn's system/init frame. @@ -126,7 +127,7 @@ beforeEach(async () => { accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: join(root, 'claude-home') }, providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION_ID, leafUuid: null } }) - expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, params)).toMatchObject({ ok: true }) await adapter.awaitStarted(SESSION) await Promise.all(lifecycle) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index 8833ac4ee31b..51bb65c9b654 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -24,6 +24,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } @@ -58,7 +59,7 @@ beforeEach(async () => { mintSpawnToken: () => 'spawn-1', now: () => NOW }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) }) afterEach(async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts index d84fa438e274..98be60a260d7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-close.test.ts @@ -21,6 +21,7 @@ import { } from './structured-agent-session-rest-test-rig' import { StructuredAgentSessionIdleSweep } from './structured-agent-session-idle-sweep' import { hostTestAttachParams } from './structured-agent-session-host-test-data' +import { attachForTests } from './structured-agent-session-attach-test-support' let rig: RestTestRig @@ -254,7 +255,7 @@ describe('a start that never finishes (P2-15)', () => { return resolve.call(this, input) }) const reader = collectSubscriber() - const attached = await rig.host.attach(CALLER, hostTestAttachParams(null)) + const attached = await attachForTests(rig.host, CALLER, hostTestAttachParams(null)) expect(attached.ok).toBe(true) await rig.host.subscribe({ id: 'reader', sessionId: SESSION, emit: reader.emit }) const sent = await rig.host.send(CALLER, restTestSend('stuck behind the start', fence())) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index 55f58d06a591..81d3b30cbf33 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -26,6 +26,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } @@ -90,7 +91,7 @@ beforeEach(async () => { mintSpawnToken: () => 'spawn-1', now: () => NOW }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) }) afterEach(async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts index 22295ea6e864..536721fadf07 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts @@ -25,6 +25,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } const CHILD_PID = 4321 @@ -106,7 +107,7 @@ describe('a host that dies while its Codex child is starting', () => { it('leaves the spawned child recorded, so the next host stops it by identity and starts over', async () => { const first = await openStore('dying') const dying = host('dying', first, adapterThatNeverFinishesStarting()) - void dying.attach(CALLER, hostTestAttachParams(null)).catch(() => {}) + void attachForTests(dying, CALLER, hostTestAttachParams(null)).catch(() => {}) await vi.waitFor(() => expect(first.getRecord(SESSION)?.lease.ownerProcess).toBeTruthy()) // Durable before the handshake returned: the only record the next host will have. expect(first.getRecord(SESSION)?.lease).toMatchObject({ @@ -147,7 +148,9 @@ describe('a host that dies while its Codex child is starting', () => { }) // What the next send's delivery does: start at the record's current fence. const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null - expect(await relaunched.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: true }) + expect(await attachForTests(relaunched, CALLER, hostTestAttachParams(fence))).toMatchObject({ + ok: true + }) expect(restarted.connections).toHaveLength(1) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', @@ -179,7 +182,7 @@ describe('a create replayed after the host that ran it died', () => { const params = hostTestAttachParams(null) const first = await openStore('dying') const dying = host('dying', first, dyingAdapter()) - void dying.attach(CALLER, params).catch(() => {}) + void attachForTests(dying, CALLER, params).catch(() => {}) await vi.waitFor(() => expect(first.getRecord(SESSION)?.lease).toMatchObject( dyingAdapter === adapterThatNeverSpawns @@ -212,7 +215,7 @@ describe('a create replayed after the host that ran it died', () => { runtimeFence: 2 }) - const replayed = await relaunched.attach(CALLER, params) + const replayed = await attachForTests(relaunched, CALLER, params) // Before, `agent_session_ownership_unknown` while the row was pending, then `_operation_expired`. expect(replayed.ok ? null : replayed.refusal.code).toBeNull() expect(replayed).toMatchObject({ ok: true, value: { sessionId: SESSION, fence: 3 } }) @@ -227,7 +230,7 @@ describe('a create replayed after the host that ran it died', () => { ).toEqual({ status: 'succeeded', sessionId: SESSION }) // Settled now: the same id replays that answer and never starts a second agent. - await expect(relaunched.attach(CALLER, params)).resolves.toMatchObject({ + await expect(attachForTests(relaunched, CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index 9d396b58ffa7..e203abc57560 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -45,6 +45,7 @@ import { HOST_TEST_LOCATION as LOCATION, HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' +import { attachForTests } from './structured-agent-session-attach-test-support' const PROVIDER_SESSION = 'provider-session-alpha-1' /** The tool call's row: the last thing the provider wrote before the crash. */ @@ -249,7 +250,8 @@ function turnStatesSent(events: readonly AgentSessionSubscribeEvent[]) { } function attach(fence: number) { - return host.attach( + return attachForTests( + host, { callerKey: 'client-1' }, hostTestAttachParams(fence, { provider: 'claude', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create-at-rest.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create-at-rest.ts new file mode 100644 index 000000000000..2e1079408922 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create-at-rest.ts @@ -0,0 +1,148 @@ +// Creating a chat: its record and its journal, at rest. No agent starts here. The chat's first +// message starts one (`structured-agent-session-agent-start`), so a start that fails is reported +// on that message and never fails the create. + +import { refuse } from '../../../shared/agent-session-wire-refusals' +import type { + AgentSessionAttachResult, + AgentSessionMutationResult +} from '../../../shared/agent-session-wire' +import { + adoptedProviderHandleLink, + admitAttachOrRefuse, + classifyStoreFailure, + type AgentSessionAttachParams +} from './structured-agent-session-attach' +import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' +import { adapterSupportsCreateIfDeclared } from './structured-agent-session-provider-support' +import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' +import { readAgentSessionHydrationPage } from './agent-session-history-page' +import { pinnedAgentSessionLaunchArgs } from './structured-agent-session-launch-env' +import { + importAdoptedTranscript, + prepareAdoptedTranscript +} from './structured-agent-session-adopted-import' + +type CreateResult = AgentSessionMutationResult + +export function createStructuredAgentSessionAtRest( + context: StructuredAgentSessionAttachContext, + callerKey: string, + params: AgentSessionAttachParams +): Promise { + return context.serialize(params.envelope.sessionId, () => + createAtRest(context, callerKey, params) + ) +} + +async function createAtRest( + context: StructuredAgentSessionAttachContext, + callerKey: string, + params: AgentSessionAttachParams +): Promise { + const { store, adapter } = context.deps + const sessionId = params.envelope.sessionId + if (params.envelope.expectedRuntimeFence !== null) { + // A create names no fence; one that does is asking to take over an existing chat. + return { + ok: false, + refusal: refuse( + 'agent_session_operation_invalid', + { reason: 'requestMalformed' }, + 'A create cannot name a runtime fence.' + ) + } + } + const admitted = admitAttachOrRefuse(params) + if (!admitted.ok) { + return admitted + } + if (!adapterSupportsCreateIfDeclared(adapter, params.location, params.agent)) { + return { + ok: false, + refusal: refuse( + 'structured_agent_session_unsupported', + { reason: 'hostUnsupported' }, + 'This execution host cannot create the requested structured agent session.' + ) + } + } + // Read and checked before the record claims the provider conversation it adopts. + const transcript = store.getRecord(sessionId) + ? { ok: true as const, items: null } + : await prepareAdoptedTranscript(params) + if (!transcript.ok) { + return transcript + } + let created: Awaited> + try { + const now = context.now() + created = await store.createAtRest({ + sessionId, + location: params.location, + provider: params.provider, + accountHome: params.accountHome, + ...(params.options ? { options: params.options } : {}), + ...(params.surfaceTabId ? { surfaceTabId: params.surfaceTabId } : {}), + ...(await pinnedAgentSessionLaunchArgs(context.deps.resolveLaunchArgs, params)), + ...(params.adopt + ? { adoptedHandleLink: adoptedProviderHandleLink(params.adopt.providerHandle, now) } + : {}), + claimKeyId: context.deps.claimKeyId, + operation: { + callerKey, + operationId: params.envelope.clientOperationId, + fingerprint: admitted.fingerprint + }, + now + }) + } catch (error) { + return { ok: false, refusal: classifyStoreFailure(error, null, store.getRecord(sessionId)) } + } + if (created.replayed && created.operationRow.outcome.status !== 'succeeded') { + // A create from before chats were created at rest: it answers only as it ended. + const replay = resolveAgentSessionReplayOutcome({ + operationId: params.envelope.clientOperationId, + outcome: created.operationRow.outcome, + reconstruct: () => null + }) + if (replay.decision === 'refuse') { + return { ok: false, refusal: replay.refusal } + } + } + const conversation = await context.openConversation(created.record.sessionId) + if (!conversation) { + return { + ok: false, + refusal: refuse( + 'agent_session_identity_required', + { reason: 'recordMissing' }, + 'No structured session exists by that id.' + ) + } + } + const { journal } = conversation + if (!created.replayed) { + await importAdoptedTranscript( + params, + { journal, unconfirmedClientMessageIds: [] }, + created.record, + transcript.items + ) + } + const fence = created.record.lease.runtimeFence + const tabId = store.getSessionTabId(created.record.sessionId) + return { + ok: true, + replayed: created.replayed, + fence, + cursor: journal.cursor(), + value: { + sessionId: created.record.sessionId, + fence, + page: readAgentSessionHydrationPage(journal, fence), + unconfirmedClientMessageIds: [], + ...(tabId ? { tabId } : {}) + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts index d1a45fc469a7..adcfba2cab05 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts @@ -23,6 +23,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'claude stream-json exited (code 1): stderr tail' @@ -97,13 +98,13 @@ describe('failed create owner verdict', () => { ownerVerdict: 'exited' } - await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) - await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) + await expect(attachForTests(host, CALLER, first)).resolves.toEqual({ ok: false, refusal }) + await expect(attachForTests(host, CALLER, first)).resolves.toEqual({ ok: false, refusal }) expect(acquire).toHaveBeenCalledOnce() const retry = hostTestAttachParams(null) expect(retry.envelope.clientOperationId).not.toBe(first.envelope.clientOperationId) - await expect(host.attach(CALLER, retry)).resolves.toMatchObject({ ok: true }) + await expect(attachForTests(host, CALLER, retry)).resolves.toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') } @@ -132,13 +133,15 @@ describe('failed create owner verdict', () => { ownerVerdict: 'exited' } - await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) - await expect(host.attach(CALLER, first)).resolves.toEqual({ ok: false, refusal }) + await expect(attachForTests(host, CALLER, first)).resolves.toEqual({ ok: false, refusal }) + await expect(attachForTests(host, CALLER, first)).resolves.toEqual({ ok: false, refusal }) expect(acquire).toHaveBeenCalledOnce() - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ - ok: true - }) + await expect(attachForTests(host, CALLER, hostTestAttachParams(null))).resolves.toMatchObject( + { + ok: true + } + ) expect(acquire).toHaveBeenCalledTimes(2) } ) @@ -146,7 +149,7 @@ describe('failed create owner verdict', () => { it('answers an acquisition refusal with its verdict directly', async () => { acquire.mockRejectedValueOnce(new AgentSessionAcquisitionRefusal('not signed in')) - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toEqual({ + await expect(attachForTests(host, CALLER, hostTestAttachParams(null))).resolves.toEqual({ ok: false, refusal: { code: 'agent_session_operation_invalid', @@ -161,8 +164,8 @@ describe('failed create owner verdict', () => { acquire.mockRejectedValueOnce(new AgentSessionAcquisitionExitUnprovenError(new Error('hung'))) const first = hostTestAttachParams(null) - await expect(host.attach(CALLER, first)).rejects.toThrow() - const replay = await host.attach(CALLER, first) + await expect(attachForTests(host, CALLER, first)).rejects.toThrow() + const replay = await attachForTests(host, CALLER, first) expect(replay).toMatchObject({ ok: false, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts index 06538d783109..e3ae13972a83 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts @@ -21,6 +21,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'claude stream-json exited (code 1): claude: not signed in' @@ -82,7 +83,7 @@ describe('a create that fails after its child wrote through the unbound sink', ( throw cause }) - const failed = host.attach(CALLER, hostTestAttachParams(null)) + const failed = attachForTests(host, CALLER, hostTestAttachParams(null)) await (cause instanceof AgentSessionPreSpawnError ? expect(failed).rejects.toThrow("Codex couldn't restart. Send your message to try again.") : expect(failed).resolves.toMatchObject({ @@ -90,9 +91,11 @@ describe('a create that fails after its child wrote through the unbound sink', ( refusal: { message: "Codex couldn't restart. Send your message to try again." } })) - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ - ok: true - }) + await expect(attachForTests(host, CALLER, hostTestAttachParams(null))).resolves.toMatchObject( + { + ok: true + } + ) await expect(host.flushAllStreamedEvents()).resolves.toBeUndefined() expect(acquire).toHaveBeenCalledTimes(2) } @@ -102,7 +105,7 @@ describe('a create that fails after its child wrote through the unbound sink', ( ['refused', new Error(EXIT_REASON)], ['thrown', new AgentSessionPreSpawnError(new Error(EXIT_REASON))] ])('releases the sink when a resume of a still-indexed session is %s', async (_how, cause) => { - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ + await expect(attachForTests(host, CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: true }) const exitedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 @@ -121,13 +124,13 @@ describe('a create that fails after its child wrote through the unbound sink', ( }) // The session stays indexed across this failure: it is a resume, not a create. - const failed = host.attach(CALLER, hostTestAttachParams(releasedFence)) + const failed = attachForTests(host, CALLER, hostTestAttachParams(releasedFence)) await (cause instanceof AgentSessionPreSpawnError ? expect(failed).rejects.toThrow("Codex couldn't restart. Send your message to try again.") : expect(failed).resolves.toMatchObject({ ok: false })) const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - await expect(host.attach(CALLER, hostTestAttachParams(fence))).resolves.toMatchObject({ + await expect(attachForTests(host, CALLER, hostTestAttachParams(fence))).resolves.toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledTimes(3) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts index fdaa5b72d506..ecbf6916ecba 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -20,7 +20,7 @@ import { createTrackedJournalOpener, openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' +import { attachStructuredAgentSessionUnderSerialize } from './structured-agent-session-attach-orchestration' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' @@ -200,7 +200,7 @@ function attachContext( const attachParams = { envelope: { sessionId: SESSION, clientOperationId: 'op-1' } -} as unknown as Parameters[2] +} as unknown as Parameters[2] describe('a session that leaves the host without an explicit close', () => { it('forgets the retained exact subject after the record and live session are deleted first', async () => { @@ -241,7 +241,11 @@ describe('a session that leaves the host without an explicit close', () => { const drop = vi.spyOn(server, 'dropStructuredStatus') await expect( - attachStructuredAgentSession(attachContext(sessions, feed), 'caller-1', attachParams) + attachStructuredAgentSessionUnderSerialize( + attachContext(sessions, feed), + 'caller-1', + attachParams + ) ).rejects.toThrow('attach failed after acquisition') expect(sessions.has(SESSION)).toBe(true) @@ -255,7 +259,11 @@ describe('a session that leaves the host without an explicit close', () => { const { feed, sessions } = await workingSession() await expect( - attachStructuredAgentSession(attachContext(sessions, feed), 'caller-1', attachParams) + attachStructuredAgentSessionUnderSerialize( + attachContext(sessions, feed), + 'caller-1', + attachParams + ) ).rejects.toThrow('attach failed after acquisition') const events: unknown[] = [] diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts index b18279392b92..0a99e7c6b59c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts @@ -24,6 +24,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } @@ -149,7 +150,7 @@ afterEach(async () => { describe('grouped question admission', () => { it('reads the packed answer an older client sends into structured answers', async () => { - const attached = await host.attach(CALLER, attachParams()) + const attached = await attachForTests(host, CALLER, attachParams()) expect(attached.ok).toBe(true) const prompt = await seedGroupedQuestion() const answers = [ @@ -173,7 +174,7 @@ describe('grouped question admission', () => { }) it('takes structured answers past the old option-id bound and keeps the packed form for older readers', async () => { - const attached = await host.attach(CALLER, attachParams()) + const attached = await attachForTests(host, CALLER, attachParams()) expect(attached.ok).toBe(true) const prompt = await seedGroupedQuestion() const typed = 'Proceed with the replacement, but wait for the capture. '.repeat(40) @@ -205,7 +206,7 @@ describe('grouped question admission', () => { }) it('refuses answers that do not match the questions without reaching the provider', async () => { - const attached = await host.attach(CALLER, attachParams()) + const attached = await attachForTests(host, CALLER, attachParams()) expect(attached.ok).toBe(true) const prompt = await seedGroupedQuestion() const answers = [{ questionId: 'q1', optionIds: ['target-web'] }] @@ -243,7 +244,7 @@ describe('a grouped question answered again', () => { } it('answers a re-click with the same answers from the resolution it holds', async () => { - expect((await host.attach(CALLER, attachParams())).ok).toBe(true) + expect((await attachForTests(host, CALLER, attachParams())).ok).toBe(true) const prompt = await seedGroupedQuestion() const first = await answer(prompt, answers) @@ -256,7 +257,7 @@ describe('a grouped question answered again', () => { }) it('refuses different answers to a resolved question as moved on', async () => { - expect((await host.attach(CALLER, attachParams())).ok).toBe(true) + expect((await attachForTests(host, CALLER, attachParams())).ok).toBe(true) const prompt = await seedGroupedQuestion() await answer(prompt, answers) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts index 2971ce4f79c6..40f3d7f4878c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts @@ -19,6 +19,7 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } @@ -77,7 +78,9 @@ describe('abandoning a structured agent-session host', () => { mintSpawnToken: () => 'spawn-a', now: () => NOW }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(null))).toMatchObject({ + ok: true + }) // The conversation stays and its provider child does not, so the next send makes the delivery // loop start one — the shape the refusal-oracle spec ends on. await host.close(SESSION, 'evict') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts index f29d1b68e69c..48b5c8bf1d82 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts @@ -29,6 +29,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { attachForTests } from './structured-agent-session-attach-test-support' const journals = createTrackedJournalOpener() @@ -93,7 +94,7 @@ function adapter(): StructuredAgentSessionAdapter { } async function attach(): Promise { - const result = await host.attach(CALLER, attachParams()) + const result = await attachForTests(host, CALLER, attachParams()) expect(result.ok).toBe(true) return store.getRecord(SESSION) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 4b1993ea3026..b184b33231ca 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -28,6 +28,7 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' let root: string let store: AgentSessionRecordStore @@ -55,7 +56,7 @@ beforeEach(() => { describe('attach', () => { it('reserves the lease, spawns through the adapter, and opens the journal', async () => { - const result = await host.attach(CALLER, attachParams()) + const result = await attachForTests(host, CALLER, attachParams()) expect(result).toMatchObject({ ok: true, replayed: false }) const record = store.getRecord(SESSION) expect(record?.lease.ownerProcess?.pid).toBe(4242) @@ -64,7 +65,7 @@ describe('attach', () => { it('refuses a payload the client fingerprinted wrong', async () => { const params = attachParams() - const result = await host.attach(CALLER, { + const result = await attachForTests(host, CALLER, { ...params, envelope: { ...params.envelope, payloadFingerprint: 'a'.repeat(64) } }) @@ -79,7 +80,7 @@ describe('attach', () => { providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: null } }) - expect(await host.attach(CALLER, params)).toMatchObject({ + expect(await attachForTests(host, CALLER, params)).toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_invalid' } }) @@ -88,13 +89,13 @@ describe('attach', () => { it('refuses a second create against a live session', async () => { await attach() - expect(await host.attach(CALLER, attachParams())).toMatchObject({ ok: false }) + expect(await attachForTests(host, CALLER, attachParams())).toMatchObject({ ok: false }) }) it('replays a retried attach instead of reserving a second owner', async () => { const params = attachParams() - await host.attach(CALLER, params) - const retry = await host.attach(CALLER, params) + await attachForTests(host, CALLER, params) + const retry = await attachForTests(host, CALLER, params) expect(retry).toMatchObject({ ok: true, replayed: true }) }) @@ -151,10 +152,12 @@ describe('attach', () => { ownerVerdict: 'exited' } } - expect(await host.attach(CALLER, params)).toEqual(refused) - expect(await host.attach(CALLER, params)).toEqual(refused) + expect(await attachForTests(host, CALLER, params)).toEqual(refused) + expect(await attachForTests(host, CALLER, params)).toEqual(refused) const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - expect(await host.attach(CALLER, ensureParams(releasedFence))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, ensureParams(releasedFence))).toMatchObject({ + ok: true + }) expect(acquire).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') expect(releaseAcquisition).toHaveBeenCalledWith({ sessionId: SESSION }) @@ -163,7 +166,7 @@ describe('attach', () => { it('reaps an acquisition when process identity commit fails', async () => { vi.spyOn(store, 'commitProcessIdentity').mockRejectedValueOnce(new Error('commit failed')) - await expect(host.attach(CALLER, attachParams())).resolves.toMatchObject({ + await expect(attachForTests(host, CALLER, attachParams())).resolves.toMatchObject({ ok: false, refusal: { message: "Codex couldn't restart. Send your message to try again.", @@ -199,7 +202,7 @@ describe('attach', () => { now: NOW }) - const replacement = host.attach(CALLER, ensureParams(released.lease.runtimeFence)) + const replacement = attachForTests(host, CALLER, ensureParams(released.lease.runtimeFence)) await new Promise((resolve) => setImmediate(resolve)) expect(acquire).toHaveBeenCalledTimes(1) @@ -577,7 +580,7 @@ describe('restart', () => { /** The refusal a restarted host owes a client holding the dead generation's * fence: stale, with the live fence attached so the retry can succeed. */ async function staleFenceFrom(held: number): Promise { - const refused = await host.attach(CALLER, ensureParams(held)) + const refused = await attachForTests(host, CALLER, ensureParams(held)) if (refused.ok) { throw new Error('a fence from the previous host generation was accepted') } @@ -592,7 +595,7 @@ describe('restart', () => { const held = before?.lease.runtimeFence ?? 0 await reboot(async () => ({ outcome: 'pid-absent' })) - const reattached = await host.attach(CALLER, ensureParams(await staleFenceFrom(held))) + const reattached = await attachForTests(host, CALLER, ensureParams(await staleFenceFrom(held))) expect(reattached).toMatchObject({ ok: true }) expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(false) expect(store.getRecord(SESSION)?.lease.ownerProcess?.pid).toBe(4242) @@ -637,7 +640,7 @@ describe('restart', () => { // The recovery stage clears on evidence at startup; the child comes back only once work // starts it — here the explicit attach a send's delivery would make. const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - expect(await host.attach(CALLER, ensureParams(fence))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, ensureParams(fence))).toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ @@ -669,7 +672,8 @@ describe('restart', () => { return settled(input) }) - const start = host.attach( + const start = attachForTests( + host, CALLER, ensureParams(store.getRecord(SESSION)?.lease.runtimeFence ?? 0) ) @@ -702,7 +706,9 @@ describe('restart', () => { ) acquire.mockClear() - expect(await host.attach(CALLER, ensureParams(await staleFenceFrom(held)))).toMatchObject({ + expect( + await attachForTests(host, CALLER, ensureParams(await staleFenceFrom(held))) + ).toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledOnce() @@ -719,8 +725,8 @@ describe('restart', () => { .mockResolvedValue({ outcome: 'pid-absent' }) await reboot(probe) - await expect(host.attach(CALLER, ensureParams(held))).rejects.toThrow('probe exploded') - const reattached = await host.attach(CALLER, ensureParams(await staleFenceFrom(held))) + await expect(attachForTests(host, CALLER, ensureParams(held))).rejects.toThrow('probe exploded') + const reattached = await attachForTests(host, CALLER, ensureParams(await staleFenceFrom(held))) expect(reattached).toMatchObject({ ok: true }) expect(probe).toHaveBeenCalledTimes(2) }) @@ -842,7 +848,11 @@ describe('subscribe', () => { now: NOW }) - const replacement = await host.attach(CALLER, ensureParams(released.lease.runtimeFence)) + const replacement = await attachForTests( + host, + CALLER, + ensureParams(released.lease.runtimeFence) + ) if (!replacement.ok) { throw new Error(`expected replacement owner, got ${replacement.refusal.code}`) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 90ef0b61f57e..15b1e9e12350 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -20,7 +20,7 @@ import { } from './structured-agent-session-reveal' import { structuredAgentSessionOwnerStatus } from './structured-agent-session-owner-status' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' -import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' +import { createStructuredAgentSessionAtRest } from './structured-agent-session-create-at-rest' import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import { ensureStructuredAgentSessionAgent, @@ -248,11 +248,12 @@ export class StructuredAgentSessionHost { private serialize = this.tasks.serialize.bind(this.tasks) - attach( + /** A new chat at rest; its first message starts the agent. */ + create( caller: StructuredAgentSessionCaller, params: AgentSessionAttachParams ): Promise> { - return attachStructuredAgentSession(this.attachContext(), caller.callerKey, params) + return createStructuredAgentSessionAtRest(this.attachContext(), caller.callerKey, params) } flushStreamedEvents = (sessionId: string): Promise => @@ -348,6 +349,7 @@ export class StructuredAgentSessionHost { /** Test rigs only: the collaborators the host builds itself, typed, for tests that drive them. */ collaboratorsForTests = () => ({ + attachContext: () => this.attachContext(), sessions: this.sessions, subscribers: this.subscribers, runtimeState: this.runtimeState, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index 44615a27ee82..76512cd97ad6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -29,6 +29,7 @@ import { import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } @@ -124,7 +125,7 @@ beforeEach(async () => { mintSpawnToken: () => 'spawn-a', now: () => NOW }) - expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) + expect((await attachForTests(host, CALLER, hostTestAttachParams(null))).ok).toBe(true) }) afterEach(async () => { 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 index cd26b96e5cd1..d35ec5c9a37a 100644 --- 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 @@ -16,6 +16,7 @@ import { hostTestState } from './structured-agent-session-host-test-harness' import { HOST_TEST_NOW as NOW } from './structured-agent-session-host-test-data' +import { attachForTests } from './structured-agent-session-attach-test-support' let host: StructuredAgentSessionHost @@ -45,7 +46,7 @@ async function launchAndDeliver(): Promise<{ dispatchState: string }> { const send = vi.spyOn(host, 'send') - const created = await host.attach(CALLER, attachParams()) + const created = await attachForTests(host, CALLER, attachParams()) if (!created.ok) { throw new Error(`expected a create, got ${created.refusal.code}`) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts index d19e93a5a812..97be01dbb8bd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts @@ -24,6 +24,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } @@ -69,7 +70,7 @@ type OlderBuildLease = Partial & { } async function persistFromOlderBuild(lease: OlderBuildLease): Promise { - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) const attached = store.getRecord(SESSION)?.lease await host.flushAllStreamedEvents() // Over the attached owner: the older build's stage or terminal owner kept it from releasing. @@ -263,7 +264,7 @@ describe('a record an older build left mid terminal handoff', () => { } }) const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null - expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ + expect(await attachForTests(host, CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: false, refusal: { code: 'agent_session_conflict', message: quitTerminal } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts index aa4a95470011..b31560a3b059 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts @@ -37,6 +37,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } const PROVIDER_ROW = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1' } @@ -97,7 +98,7 @@ beforeEach(async () => { mintSpawnToken: () => 'spawn-1', now: () => NOW }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) host.subscribe({ id: 'chat-1', sessionId: SESSION, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts index aa7b8a17295f..2b7e15f0451b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts @@ -21,6 +21,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } const DEFAULT_MODEL = 'gpt-default' @@ -131,7 +132,8 @@ beforeEach(async () => { mintSpawnToken: () => 'spawn-native', now: () => NOW }) - const attached = await host.attach( + const attached = await attachForTests( + host, CALLER, hostTestAttachParams(null, { accountHome: { variable: 'CODEX_HOME', path: accountHome } }) ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts index 24e9dc66f446..7400e8279908 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts @@ -32,6 +32,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } const SWEEP_MS = 5 @@ -120,7 +121,8 @@ afterEach(async () => { }) async function attachStarting(): Promise { - const created = await host.attach( + const created = await attachForTests( + host, CALLER, hostTestAttachParams(null, { provider: 'claude', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 2031cb002dcf..52f79d612172 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -40,6 +40,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { @@ -122,7 +123,7 @@ beforeEach(async () => { })) store = await openTestAgentSessionRecordStore(root) startHost() - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) await host.close(SESSION, 'evict') }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts index 411e148c586c..2a677bd224a7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts @@ -16,6 +16,7 @@ import { import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const CLAUDE_SESSION = 'claude-session' const hosts: StructuredAgentSessionHost[] = [] @@ -80,7 +81,8 @@ describe('structured session provider restore', () => { resetHostTestOperationIds() const store = await openTestAgentSessionRecordStore(root) const host = createHost(store) - const attached = await host.attach( + const attached = await attachForTests( + host, { callerKey: 'client-1' }, hostTestAttachParams(null, { provider: 'claude', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index f5e0e14b09fb..70d836a4bfdd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts @@ -23,6 +23,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } const INIT_DELAY_MS = 40 @@ -108,7 +109,7 @@ function lastPhase(): string | undefined { describe('a publish-first Claude create whose init is slow', () => { it('never persists the catalog default, and persists the reported model once started', async () => { - await expect(host.attach(CALLER, claudeParams())).resolves.toMatchObject({ ok: true }) + await expect(attachForTests(host, CALLER, claudeParams())).resolves.toMatchObject({ ok: true }) // Published, not yet answering: the record holds no model rather than a guessed one. expect(store.getRecord(SESSION)?.options?.model).toBeUndefined() @@ -124,7 +125,7 @@ describe('a publish-first Claude create whose init is slow', () => { it('keeps the saved model as intent while starting, then confirms what the child runs', async () => { const params = claudeParams() await expect( - host.attach(CALLER, { ...params, options: { model: 'opus' } }) + attachForTests(host, CALLER, { ...params, options: { model: 'opus' } }) ).resolves.toMatchObject({ ok: true }) expect(store.getRecord(SESSION)?.options?.model).toBe('opus') @@ -137,14 +138,14 @@ describe('a publish-first Claude create whose init is slow', () => { it('keeps the picked model across a resume whose new child starts on its own default', async () => { const params = claudeParams() - await host.attach(CALLER, { ...params, options: { model: 'opus' } }) + await attachForTests(host, CALLER, { ...params, options: { model: 'opus' } }) await adapter.awaitStarted(SESSION) await Promise.all(lifecycle) await host.close(SESSION, 'evict') const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 // Starting the chat again resumes the session under a new fence. - await expect(host.attach(CALLER, claudeParams(releasedFence))).resolves.toMatchObject({ + await expect(attachForTests(host, CALLER, claudeParams(releasedFence))).resolves.toMatchObject({ ok: true }) expect(store.getRecord(SESSION)?.lease.runtimeFence).toBeGreaterThan(releasedFence) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index d63af3ba7d20..155bc779e484 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -26,6 +26,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' export const QUEUED_RIG_CALLER = { callerKey: 'client-1' } @@ -89,7 +90,7 @@ export async function createQueuedMessageTestRig() { mintSpawnToken: () => 'spawn-1', now: () => NOW }) - expect(await host.attach(QUEUED_RIG_CALLER, hostTestAttachParams(null))).toMatchObject({ + expect(await attachForTests(host, QUEUED_RIG_CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts index f69fbad10316..32e6d80cce19 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts @@ -22,6 +22,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } @@ -65,7 +66,7 @@ async function stopOwner(child: ReturnType): Promise /** What a send's delivery or `agentSession.ensure` does: attach at the record's current fence. */ async function startAgent(): Promise { const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null - expect((await host.attach(CALLER, hostTestAttachParams(fence))).ok).toBe(true) + expect((await attachForTests(host, CALLER, hostTestAttachParams(fence))).ok).toBe(true) } function adapter(): StructuredAgentSessionAdapter { @@ -129,7 +130,7 @@ afterEach(async () => { describe('recovery exits', () => { it('releases an ownerless unproven acquisition, so the next start goes ahead', async () => { acquire.mockRejectedValueOnce(new Error('simulated crash before identity commit')) - await expect(host.attach(CALLER, hostTestAttachParams(null))).rejects.toThrow( + await expect(attachForTests(host, CALLER, hostTestAttachParams(null))).rejects.toThrow( 'agent_session_acquisition_exit_unproven' ) // No owner was recorded, and the adapter closed the stdio of anything it spawned. @@ -146,14 +147,14 @@ describe('recovery exits', () => { await reopenStore() openHost({ mintSpawnToken: () => 'spawn-b' }) - expect(await host.attach(CALLER, hostTestAttachParams(2))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(2))).toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledTimes(2) }) it('releases an unproven acquisition whose owner later dies, without replaying it as a handoff', async () => { // A real session first, so restart restore has a journal to read and runs the // handoff restorer over the residue instead of skipping the record. - expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) + expect((await attachForTests(host, CALLER, hostTestAttachParams(null))).ok).toBe(true) await reopenStore() // The resume fails at owner proof and cleanup cannot prove exit: the settlement @@ -163,7 +164,7 @@ describe('recovery exits', () => { mintSpawnToken: () => 'spawn-b', probeOwner: async () => ({ outcome: 'pid-absent' }) }) - await expect(host.attach(CALLER, hostTestAttachParams(2))).rejects.toThrow( + await expect(attachForTests(host, CALLER, hostTestAttachParams(2))).rejects.toThrow( 'agent_session_acquisition_exit_unproven' ) expect(store.getRecord(SESSION)?.lease).toMatchObject({ @@ -207,7 +208,7 @@ describe('recovery exits', () => { }) it('stops a surviving native child after restart instead of readopting its dead transport', async () => { - expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) + expect((await attachForTests(host, CALLER, hostTestAttachParams(null))).ok).toBe(true) await reopenStore() let orphanAlive = true @@ -223,13 +224,13 @@ describe('recovery exits', () => { stopOwnerProcess }) - const stale = await host.attach(CALLER, hostTestAttachParams(1)) + const stale = await attachForTests(host, CALLER, hostTestAttachParams(1)) expect(stale).toMatchObject({ ok: false, refusal: { code: 'agent_session_checkpoint_stale', currentFence: 2 } }) expect(stopOwnerProcess).toHaveBeenCalledWith(4242, 'SIGTERM') - const retried = await host.attach(CALLER, hostTestAttachParams(2)) + const retried = await attachForTests(host, CALLER, hostTestAttachParams(2)) expect(retried).toMatchObject({ ok: true }) // A fresh child was spawned; the orphan pid's lease did not survive as the owner. expect(acquire).toHaveBeenCalledTimes(2) @@ -241,7 +242,7 @@ describe('recovery exits', () => { }) it('heals a stranded native owner during startup restore, and spawns nothing until work asks', async () => { - expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) + expect((await attachForTests(host, CALLER, hostTestAttachParams(null))).ok).toBe(true) await reopenStore() let orphanAlive = true @@ -292,7 +293,7 @@ describe('recovery exits', () => { observedAt: NOW } }) - expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) + expect((await attachForTests(host, CALLER, hostTestAttachParams(null))).ok).toBe(true) const outgoingHost = host const outgoingStore = store diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index 83082bf923a5..717288b62648 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -29,6 +29,7 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } const METHODS = ['agentSession.setOption', 'agentSession.send'] as const @@ -98,7 +99,9 @@ async function createHarness(options: { attached?: boolean } = {}) { const harness = { root, store, host, setOption } harnesses.push(harness) if (options.attached !== false) { - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(null))).toMatchObject({ + ok: true + }) } return harness } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts index 0eaaf48448cc..d3708d3d90af 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts @@ -22,6 +22,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } @@ -70,7 +71,7 @@ beforeEach(async () => { mintSpawnToken: () => 'spawn-1', now: () => NOW }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) }) afterEach(async () => { @@ -199,7 +200,9 @@ describe('a press sent again', () => { 'pending-admission' ) const fence = store.getRecord(SESSION)!.lease.runtimeFence - expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(fence))).toMatchObject({ + ok: true + }) expect(await stop(store.getRecord(SESSION)!.lease.runtimeFence)).toMatchObject({ ok: true, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index e441a156b494..8de9cd0e4844 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -33,6 +33,7 @@ import { } from './structured-agent-session-host-test-data' import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' export const REST_TEST_CALLER = { callerKey: 'client-1' } export const IDLE_MS = STRUCTURED_AGENT_SESSION_IDLE_MS @@ -205,7 +206,7 @@ export async function createRestTestRig( /** Creates the chat, lists its tab, and sends one message so its journal is on disk. */ export async function foundRestTestChat(rig: RestTestRig): Promise { - const attached = await rig.host.attach(REST_TEST_CALLER, hostTestAttachParams(null)) + const attached = await attachForTests(rig.host, REST_TEST_CALLER, hostTestAttachParams(null)) if (!attached.ok) { throw new Error(`attach refused: ${attached.refusal.code}`) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index f981c69322b6..a986cc40f318 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -35,6 +35,7 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' /** Starts the agent explicitly — the attach a client's ensure makes — for a test that needs a * running child before its next step. Nothing else starts one ahead of a send. */ @@ -42,7 +43,8 @@ export async function startAgent(state: { host: StructuredAgentSessionHost store: AgentSessionRecordStore }): Promise { - const result = await state.host.attach( + const result = await attachForTests( + state.host, CALLER, hostTestAttachParams(state.store.getRecord(SESSION)?.lease.runtimeFence ?? null) ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts index 35425a70afe8..1fe2d6e64f6a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts @@ -27,6 +27,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } @@ -94,7 +95,7 @@ async function restartWithPersistedTurn(): Promise { resetHostTestOperationIds() const store = await openTestAgentSessionRecordStore(root) const host = createHost(store) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) const body = hostTestMessage('persisted conversation') const sent = await host.send(CALLER, { envelope: sendEnvelope(store, { body }), body }) if (!sent.ok) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts index dd878ebe0d7c..4fc718e31766 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts @@ -26,6 +26,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const caller = { callerKey: 'desktop' } const KEPT = { provider: 'codex' as const, threadId: THREAD, turnId: 'kept', ordinal: 0 } @@ -156,7 +157,7 @@ function sendParams(text: string) { /** A chat whose rewind the provider applied, whose Orca side never finished, reopened at rest. */ async function interruptedRewindAtRest(): Promise { - expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) const drop = { ...KEPT, turnId: 'drop' } sink.appendItem(KEPT, hostTestMessage('verified history'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE @@ -225,7 +226,9 @@ describe('an interrupted Codex rewind on a chat at rest (R16)', () => { describe('a rewind asked of a chat at rest (P2-23)', () => { it('starts the agent first and answers with what the provider says', async () => { - expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, caller, hostTestAttachParams(null))).toMatchObject({ + ok: true + }) sink.appendItem(KEPT, hostTestMessage('kept'), { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await host.flushStreamedEvents(SESSION) const epoch = (await host.journalSnapshot(SESSION)).cursor.epoch diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index 35e7d5af2e27..c65f95da083c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -29,6 +29,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const caller = { callerKey: 'desktop' } let directory: string @@ -106,7 +107,7 @@ afterEach(async () => { }) async function seed(acceptedSubmissions = false) { - expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) const keys = ['kept', 'drop', 'tip'].map((turnId) => ({ provider: 'codex' as const, threadId: HOST_TEST_THREAD, @@ -195,7 +196,9 @@ describe('host rewind', () => { expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('provider-succeeded') replace.mockRestore() const fence = store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence - expect(await host.attach(caller, hostTestAttachParams(fence))).toMatchObject({ ok: true }) + expect(await attachForTests(host, caller, hostTestAttachParams(fence))).toMatchObject({ + ok: true + }) expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(1) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true }) @@ -213,14 +216,16 @@ describe('host rewind', () => { expect(store.getRecord(HOST_TEST_SESSION)?.rewind).toMatchObject({ phase: 'prepared' }) recoverRewind.mockRejectedValueOnce(new Error('history still unavailable')) await expect( - host.attach( + attachForTests( + host, caller, hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ) ).rejects.toThrow('history still unavailable') expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('prepared') expect( - await host.attach( + await attachForTests( + host, caller, hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ) @@ -312,7 +317,8 @@ describe('host rewind', () => { }) expect(adapter.dispatch).not.toHaveBeenCalled() expect( - await host.attach( + await attachForTests( + host, caller, hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ) @@ -329,7 +335,8 @@ describe('host rewind', () => { await expect(host.rewind(caller, await params(target))).rejects.toThrow('read failed') recoverRewind.mockResolvedValueOnce({ ok: false, reason: 'provider-refused' }) expect( - await host.attach( + await attachForTests( + host, caller, hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ) @@ -350,7 +357,8 @@ describe('host rewind', () => { expect(store.getRecord(HOST_TEST_SESSION)?.rewind).toMatchObject({ phase: 'prepared' }) recoverRewind.mockResolvedValueOnce({ ok: false, reason: 'provider-refused' }) expect( - await host.attach( + await attachForTests( + host, caller, hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ) @@ -376,7 +384,9 @@ describe('host rewind', () => { }) it('keeps host-stamped turn and goal rows through a Codex provider hydration', async () => { - expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, caller, hostTestAttachParams(null))).toMatchObject({ + ok: true + }) const message = (turnId: string) => ({ provider: 'codex' as const, threadId: HOST_TEST_THREAD, @@ -457,7 +467,9 @@ describe('host rewind', () => { }) it('keeps each kept turn opened by the message that opened it, under its provider key', async () => { - expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, caller, hostTestAttachParams(null))).toMatchObject({ + ok: true + }) const message = (turnId: string, ordinal = 0) => ({ provider: 'codex' as const, threadId: HOST_TEST_THREAD, @@ -548,7 +560,9 @@ describe('host rewind', () => { }) it('keeps a host goal row when interrupted Codex rewind recovery rebuilds provider history', async () => { - expect(await host.attach(caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, caller, hostTestAttachParams(null))).toMatchObject({ + ok: true + }) const message = (turnId: string) => ({ provider: 'codex' as const, threadId: HOST_TEST_THREAD, @@ -593,7 +607,8 @@ describe('host rewind', () => { items: [{ identity: message('kept'), body: hostTestMessage('kept from recovery') }] }) expect( - await host.attach( + await attachForTests( + host, caller, hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ) @@ -624,7 +639,8 @@ describe('host rewind', () => { await expect(host.rewind(caller, await params(target))).rejects.toThrow('lost after revert') recoverRewind.mockResolvedValueOnce({ ok: true, items }) expect( - await host.attach( + await attachForTests( + host, caller, hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ) @@ -658,7 +674,8 @@ describe('host rewind', () => { }) const replace = vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems') await expect( - host.attach( + attachForTests( + host, caller, hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ) @@ -691,7 +708,8 @@ describe('host rewind', () => { checkpoint.mockRestore() const replace = vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems') expect( - await host.attach( + await attachForTests( + host, caller, hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) ) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index f0fa5e574085..e687b3fda353 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -28,6 +28,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } @@ -90,7 +91,7 @@ beforeEach(async () => { now: () => NOW, onEventSinkError: ({ error }) => hostErrors.push(error) }) - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) }) afterEach(async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts index 22022c1fcb67..a37f6d3f5098 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts @@ -27,6 +27,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } @@ -159,7 +160,7 @@ beforeEach(async () => { mintSpawnToken: () => `spawn-${generation + 1}`, now: () => NOW }) - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ + await expect(attachForTests(host, CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: true }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts index 17bb4b0ee1fc..11892ffcb800 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -26,6 +26,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } @@ -143,7 +144,7 @@ describe('settled attach retry', () => { }) const first = hostTestAttachParams(null) - await expect(host.attach(CALLER, first)).rejects.toThrow('journal path unavailable') + await expect(attachForTests(host, CALLER, first)).rejects.toThrow('journal path unavailable') expect(releaseAcquisition).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', @@ -157,7 +158,9 @@ describe('settled attach retry', () => { ?.outcome ).toMatchObject({ status: 'failed' }) - await expect(host.attach(CALLER, hostTestAttachParams(2))).resolves.toMatchObject({ ok: true }) + await expect(attachForTests(host, CALLER, hostTestAttachParams(2))).resolves.toMatchObject({ + ok: true + }) expect(acquire).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', @@ -201,10 +204,10 @@ describe('settled attach retry', () => { const params = hostTestAttachParams(null) publishFault.failOnPublish = 2 - await expect(host.attach(CALLER, params)).rejects.toThrow( + await expect(attachForTests(host, CALLER, params)).rejects.toThrow( 'agent session acquisition failure settlement failed' ) - expect(await host.attach(CALLER, params)).toMatchObject({ ok: true, replayed: true }) + expect(await attachForTests(host, CALLER, params)).toMatchObject({ ok: true, replayed: true }) expect(mintSpawnToken).toHaveBeenCalledOnce() expect(spawnTokens).toEqual(['spawn-safe', 'spawn-safe']) }) @@ -250,7 +253,7 @@ describe('settled attach retry', () => { const params = hostTestAttachParams(null) publishFault.failOnPublish = 2 - await expect(host.attach(CALLER, params)).rejects.toThrow( + await expect(attachForTests(host, CALLER, params)).rejects.toThrow( 'agent session acquisition failure settlement failed' ) expect(publishFault.publishCount).toBe(2) @@ -291,7 +294,7 @@ describe('settled attach retry', () => { }) // The interrupted operation's own retry continues it as a fresh reservation. - await expect(host.attach(CALLER, params)).resolves.toMatchObject({ ok: true }) + await expect(attachForTests(host, CALLER, params)).resolves.toMatchObject({ ok: true }) expect(mintSpawnToken).toHaveBeenCalledTimes(2) expect(spawnTokens).toEqual(['spawn-1', 'spawn-2']) expect(store.getRecord(SESSION)?.lease).toMatchObject({ @@ -305,7 +308,7 @@ describe('settled attach retry', () => { }) it('settles a submission the host restart left pending, and never redelivers it', async () => { - expect((await host.attach(CALLER, hostTestAttachParams(null))).ok).toBe(true) + expect((await attachForTests(host, CALLER, hostTestAttachParams(null))).ok).toBe(true) // Admitted: written to the child, acknowledgement still outstanding. The // restart below is the process fact that ends the wait, not a stopwatch. dispatch.mockImplementationOnce(async () => ({ state: 'admitted' as const })) @@ -378,7 +381,7 @@ describe('settled attach retry', () => { it('records proven acquisition cleanup as durable death evidence', async () => { acquire.mockRejectedValueOnce(new Error('resume rejected')) - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ + await expect(attachForTests(host, CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: false, refusal: { message: "Codex couldn't restart. Send your message to try again.", diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index ceafa43e502c..e72406372cb1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -42,6 +42,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' @@ -268,7 +269,7 @@ beforeEach(async () => { closeSession = vi.fn(async () => true) store = await openTestAgentSessionRecordStore(root) startHost() - await expect(host.attach(CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ + await expect(attachForTests(host, CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: true }) // The first child (generation-1) is lost at setup; the first send starts generation-2. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 871c05a6e337..0580cc654324 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -41,6 +41,7 @@ import { import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const UNEXPECTED_PROVIDER_EXIT_OUTCOME = 'Codex stopped while this response was in progress. You can continue in this conversation.' @@ -99,13 +100,15 @@ async function reboot(): Promise { } async function attach(): Promise { - expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) } /** What a send's delivery or `agentSession.ensure` does: attach at the record's current fence. */ async function startAgent(): Promise { const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null - expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(fence))).toMatchObject({ + ok: true + }) } function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts index d503559ea833..1facb9ad3fbb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts @@ -25,6 +25,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ function eventually(assertion: () => unknown): Promise { @@ -161,7 +162,8 @@ describe('a fresh chat whose Claude start fails', () => { initGate = createGate claude = fakeClaude({ exitBeforeInit: LAUNCH_FAILURE }) await expect( - host.attach( + attachForTests( + host, CALLER, hostTestAttachParams(null, { provider: 'claude', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 2981815c1f17..5ab6eb39b5e7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -50,6 +50,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } const DEAD_OWNER: AgentSessionProcessIdentity = { @@ -370,10 +371,12 @@ describe('already-wedged profiles become usable on load', () => { // The attach adjudicates the dead owner first, which moves the fence; like a client's ensure, // it is retried at the fence the refusal names. - const stale = await host.attach(CALLER, hostTestAttachParams(13)) + const stale = await attachForTests(host, CALLER, hostTestAttachParams(13)) const fence = stale.ok ? 13 : (stale.refusal.currentFence ?? 13) expect(stale.ok || stale.refusal.code === 'agent_session_checkpoint_stale').toBe(true) - expect(stale.ok || (await host.attach(CALLER, hostTestAttachParams(fence))).ok).toBe(true) + expect(stale.ok || (await attachForTests(host, CALLER, hostTestAttachParams(fence))).ok).toBe( + true + ) expect(acquire).toHaveBeenCalledOnce() expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null) @@ -411,7 +414,7 @@ describe('already-wedged profiles become usable on load', () => { await seedRunningTurn() openHost() - expect(await host.attach(CALLER, hostTestAttachParams(13))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(13))).toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledOnce() expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null) @@ -504,7 +507,7 @@ describe('already-wedged profiles become usable on load', () => { ownerProcess: null, deathEvidence: null }) - expect(await host.attach(CALLER, hostTestAttachParams(14))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(14))).toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledOnce() }) @@ -528,7 +531,9 @@ describe('already-wedged profiles become usable on load', () => { runtimeFence: 14, deathEvidence: null }) - expect(await host.attach(CALLER, hostTestAttachParams(14))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(14))).toMatchObject({ + ok: true + }) expect(acquire).toHaveBeenCalledOnce() } ) @@ -540,7 +545,7 @@ describe('already-wedged profiles become usable on load', () => { await seedRunningTurn() openHost() - expect(await host.attach(CALLER, hostTestAttachParams(13))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(13))).toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledOnce() expect(turnLifecycle('turn-1')).toEqual({ @@ -565,7 +570,7 @@ describe('already-wedged profiles become usable on load', () => { probeOwner: async () => ({ outcome: 'identity-matched', matchedOn: ['spawn-token'] }) }) const params = hostTestAttachParams(13) - expect(await host.attach(CALLER, params)).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, params)).toMatchObject({ ok: true }) const fence = store.getRecord(SESSION)!.lease.runtimeFence await restoredJournal().appendItem( { provider: 'codex', threadId: THREAD, turnId: 'turn-2', ordinal: 0 }, @@ -574,7 +579,7 @@ describe('already-wedged profiles become usable on load', () => { ) // A reconnecting client replays its attach; the same operation admits the live owner. - expect(await host.attach(CALLER, params)).toMatchObject({ ok: true, replayed: true }) + expect(await attachForTests(host, CALLER, params)).toMatchObject({ ok: true, replayed: true }) expect(acquire).toHaveBeenCalledOnce() expect(turnLifecycle('turn-2')).toEqual({ turnId: 'turn-2', state: 'running', startedAt: NOW }) @@ -693,7 +698,9 @@ describe('already-wedged profiles become usable on load', () => { expect(order).toEqual([]) expect(scan).not.toHaveBeenCalled() const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null - expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: true }) + expect(await attachForTests(host, CALLER, hostTestAttachParams(fence))).toMatchObject({ + ok: true + }) expect(order).toEqual(['acquire']) }) @@ -713,7 +720,7 @@ describe('already-wedged profiles become usable on load', () => { // A terminal agent keeps its transport across a restart, so an unanswered probe is not a way in. const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null - expect(await host.attach(CALLER, hostTestAttachParams(fence))).toMatchObject({ + expect(await attachForTests(host, CALLER, hostTestAttachParams(fence))).toMatchObject({ ok: false, refusal: { code: 'agent_session_conflict' } }) diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index 475df54faa4f..85d4b28260cc 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -23,6 +23,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' const caller = { callerKey: 'desktop' } let directory: string @@ -139,7 +140,7 @@ beforeEach(async () => { } await openHost() expect( - await host.attach(caller, hostTestAttachParams(null, { options: { effort: 'low' } })) + await attachForTests(host, caller, hostTestAttachParams(null, { options: { effort: 'low' } })) ).toMatchObject({ ok: true }) await host.setSessionTabVisibility(HOST_TEST_SESSION, true) }) @@ -296,7 +297,8 @@ describe('/clear starts nothing', () => { it('copies the launch arguments the source was pinned to', async () => { const pinned = 'session-pinned' expect( - await host.attach( + await attachForTests( + host, caller, hostTestAttachParams(null, { envelope: { @@ -667,7 +669,7 @@ describe('what an older build left', () => { now: clock }) ).toMatchObject({ admission: { decision: 'admit' } }) - expect(await host.attach(caller, orphanStart)).toMatchObject({ ok: true }) + expect(await attachForTests(host, caller, orphanStart)).toMatchObject({ ok: true }) await restartHost() await host.restoreReadableSessions(store.listVisibleSessionIds()) diff --git a/src/main/runtime/agent-session-at-rest-create.ts b/src/main/runtime/agent-session-at-rest-create.ts new file mode 100644 index 000000000000..3ea39be9304f --- /dev/null +++ b/src/main/runtime/agent-session-at-rest-create.ts @@ -0,0 +1,84 @@ +// A chat's creation: its record at rest and the operation row that answers a retry of it, in one +// write. Nothing starts here; the chat's first message starts its agent, as /clear's new chat does. + +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' +import { + agentSessionOperationKey, + pendingAgentSessionOperationRow, + type AgentSessionOperationRow +} from '../../shared/agent-session-operation-ledger' +import { isAgentSessionOptions, type AgentSessionRecord } from '../../shared/agent-session-record' +import { isAgentSessionLaunchArgs } from '../../shared/agent-session-launch-args' +import type { AgentSessionStoreState } from './agent-session-record-store-file' +import { foundAgentSessionRecord } from './agent-session-record-founding' +import { + assertAdoptedConversationUnowned, + assertReservedTabUnheld, + evaluateAgentSessionReserveOperation, + requireAgentSessionRecordForReplay, + type AgentSessionReserveRequest +} from './agent-session-reservation-admission' + +export type AgentSessionAtRestCreateRequest = Pick< + AgentSessionReserveRequest, + | 'sessionId' + | 'location' + | 'provider' + | 'accountHome' + | 'launchArgs' + | 'options' + | 'surfaceTabId' + | 'adoptedHandleLink' + | 'claimKeyId' + | 'operation' + | 'now' +> + +export type AgentSessionAtRestCreateResult = { + record: AgentSessionRecord + /** The row a replay answers from; a fresh create's is already settled as succeeded. */ + operationRow: AgentSessionOperationRow + replayed: boolean +} + +export function commitAgentSessionAtRestCreate( + state: AgentSessionStoreState, + request: AgentSessionAtRestCreateRequest +): AgentSessionAtRestCreateResult { + const decision = evaluateAgentSessionReserveOperation(state, request) + if (decision.decision === 'refused') { + throw agentSessionRefusalError(decision.code, decision.details) + } + if (decision.decision === 'replay') { + return { + record: requireAgentSessionRecordForReplay(state, decision.row, request.sessionId), + operationRow: decision.row, + replayed: true + } + } + if (request.launchArgs && !isAgentSessionLaunchArgs(request.launchArgs)) { + throw new Error('agent_session_launch_args_invalid') + } + if (request.options && !isAgentSessionOptions(request.options)) { + throw new Error('agent_session_options_invalid') + } + if (state.unreadableRecords.has(request.sessionId)) { + throw agentSessionRefusalError('execution_owner_reconciling', { reason: 'recordUnreadable' }) + } + if (state.records.has(request.sessionId)) { + throw agentSessionRefusalError('agent_session_conflict', { reason: 'sessionExists' }) + } + assertAdoptedConversationUnowned(state, request) + assertReservedTabUnheld(state, request) + const record = foundAgentSessionRecord(request, request, request.adoptedHandleLink) + const operationRow: AgentSessionOperationRow = { + ...pendingAgentSessionOperationRow({ ...request.operation, now: request.now }), + outcome: { status: 'succeeded', sessionId: request.sessionId } + } + state.records.set(record.sessionId, record) + state.operations.set( + agentSessionOperationKey(operationRow.callerKey, operationRow.operationId), + operationRow + ) + return { record, operationRow, replayed: false } +} diff --git a/src/main/runtime/agent-session-record-founding.ts b/src/main/runtime/agent-session-record-founding.ts index d759a2461e0c..3deae4f51acf 100644 --- a/src/main/runtime/agent-session-record-founding.ts +++ b/src/main/runtime/agent-session-record-founding.ts @@ -2,6 +2,7 @@ import { AGENT_SESSION_RECORD_SCHEMA_VERSION, type AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' /** Who a conversation's agent is and how it launches: the same whether it is created or founded. */ export type AgentSessionRecordIdentity = Pick< @@ -27,16 +28,18 @@ export function agentSessionRecordIdentityFields( } /** - * A conversation no agent has run yet, at rest: its first send starts one. The empty handle chain - * is what makes that start a fresh conversation rather than a resume. + * A conversation no agent has run yet, at rest: its first send starts one. An empty handle chain + * makes that start a fresh conversation; an adopted link makes it a resume. */ export function foundAgentSessionRecord( identity: AgentSessionRecordIdentity, - lease: { claimKeyId: string; now: number } + lease: { claimKeyId: string; now: number }, + /** The provider conversation a create adopts; its first start resumes it instead. */ + adoptedHandleLink?: AgentSessionProviderHandleLink ): AgentSessionRecord { return { ...agentSessionRecordIdentityFields(identity, lease.now), - providerHandleChain: [], + providerHandleChain: adoptedHandleLink ? [adoptedHandleLink] : [], lease: { sessionId: identity.sessionId, runtimeKind: 'native', diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 9f1c5bf6e7f9..4f470bf69a00 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -70,6 +70,11 @@ import { setAgentSessionTabVisibility, showAgentSessionTabs } from './agent-sess import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import { loadAgentSessionStoreRows } from './agent-session-record-rows' import { AgentSessionStoreTransactions } from './agent-session-store-transactions' +import { + commitAgentSessionAtRestCreate, + type AgentSessionAtRestCreateRequest, + type AgentSessionAtRestCreateResult +} from './agent-session-at-rest-create' export const AGENT_SESSION_LEASE_TTL_MS = 30_000, AGENT_SESSION_LEASE_RENEW_INTERVAL_MS = 10_000 @@ -160,6 +165,12 @@ export class AgentSessionRecordStore { commitConversationClear = (clear: AgentSessionConversationClear): Promise => this.transact((draft) => commitConversationClearRecord(draft, clear)) + /** A new chat at rest and the operation row its create answers retries from, in one write. */ + createAtRest = ( + request: AgentSessionAtRestCreateRequest + ): Promise => + this.transact((draft) => commitAgentSessionAtRestCreate(draft, request)) + /** Unfenced on purpose: the name is a durable note, so writing it never contends with the * writer lease. `null` clears it. */ setConversationName = (sessionId: string, name: string | null): Promise => diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index db5707d3b4d8..9878f1f25b6a 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -91,7 +91,7 @@ export type AgentSessionReserveResult = { export function evaluateAgentSessionReserveOperation( state: AgentSessionStoreState, - request: AgentSessionReserveRequest + request: Pick ): AgentSessionOperationDecision { state.operations = pruneAgentSessionOperationRows(state.operations, request.now) return evaluateAgentSessionOperation({ @@ -226,9 +226,9 @@ export function applyAgentSessionReservation( * compare-and-swap never collides and both would pass. Codex permits two app-servers on one thread * silently, so the cost of missing this is a corrupted conversation rather than an error. */ -function assertAdoptedConversationUnowned( +export function assertAdoptedConversationUnowned( state: AgentSessionStoreState, - request: AgentSessionReserveRequest + request: Pick ): void { const adopted = request.adoptedHandleLink if (!adopted) { @@ -255,9 +255,9 @@ function assertAdoptedConversationUnowned( * Checked, not claimed: the id is taken when the chat's tab is published, so a create that never * gets that far leaves nothing in the table to restore or release. */ -function assertReservedTabUnheld( +export function assertReservedTabUnheld( state: AgentSessionStoreState, - request: AgentSessionReserveRequest + request: Pick ): void { if (request.surfaceTabId === undefined) { return diff --git a/src/main/runtime/claude-structured-exit-mid-response.test.ts b/src/main/runtime/claude-structured-exit-mid-response.test.ts index fd30df7dad0e..1b0a8d84d3e3 100644 --- a/src/main/runtime/claude-structured-exit-mid-response.test.ts +++ b/src/main/runtime/claude-structured-exit-mid-response.test.ts @@ -11,6 +11,7 @@ import { createScriptedClaudeRuntime, scriptedClaudeExitError } from './structured-claude-scripted-runtime-test-support' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' const SESSION = 'claude-exit-mid-response' const CALLER = { callerKey: 'client-1' } @@ -45,7 +46,9 @@ async function send(host: StructuredAgentSessionHost, text: string): Promise { it('says Claude stopped, and that the conversation can continue', async () => { const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(SESSION, null)) + ).resolves.toMatchObject({ ok: true }) await send(host, 'hello') diff --git a/src/main/runtime/claude-structured-failed-start-resume.test.ts b/src/main/runtime/claude-structured-failed-start-resume.test.ts index cd4f44d59f8a..e419ee36c541 100644 --- a/src/main/runtime/claude-structured-failed-start-resume.test.ts +++ b/src/main/runtime/claude-structured-failed-start-resume.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launch-resolution' import { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' const SESSION = 'claude-failed-start' const CALLER = { callerKey: 'client-1' } @@ -21,7 +22,9 @@ describe('a Claude chat whose first start died before initialize', () => { it('resumes on reopen, launching its id fresh and continuing the same chain', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(SESSION, null)) + ).resolves.toMatchObject({ ok: true }) claude.child(SESSION).exit(new Error('claude stream-json exited (code 1): not signed in')) @@ -31,7 +34,8 @@ describe('a Claude chat whose first start died before initialize', () => { // The user signs in and reopens the chat. claude.behave(SESSION, {}) - const reopened = await host.attach( + const reopened = await attachForTests( + host, CALLER, claude.attachParams(SESSION, failed?.lease.runtimeFence ?? null) ) @@ -61,7 +65,7 @@ describe('a Claude chat whose CLI exits the moment it is spawned', () => { claude.behave(SESSION, { exitsDuringSpawn: { diagnostic, at } }) const host = await claude.install() - const created = await host.attach(CALLER, claude.attachParams(SESSION, null)) + const created = await attachForTests(host, CALLER, claude.attachParams(SESSION, null)) expect(created).toMatchObject({ ok: false, diff --git a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts index 9fa9cbfcfa6a..f4b81fceb52c 100644 --- a/src/main/runtime/claude-structured-idle-releases-unknown.test.ts +++ b/src/main/runtime/claude-structured-idle-releases-unknown.test.ts @@ -15,6 +15,7 @@ import { hasUnansweredStructuredAgentSessionDispatch } from '../../shared/struct import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' const SESSION = 'claude-idle-release' const PROVIDER_SESSION = claudeSessionIdForOrcaSession(SESSION) @@ -70,7 +71,9 @@ function sessionState(state: 'running' | 'idle'): Record { describe('a live Claude chat whose CLI reports idle', () => { it('releases a send whose write ended in doubt, and leaves a pending one alone', async () => { const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(SESSION, null)) + ).resolves.toMatchObject({ ok: true }) const child = claude.child(SESSION) @@ -128,7 +131,9 @@ describe('a live Claude chat whose CLI took a send and let it go without an echo ['it went idle with no terminal state', 'idle', DISPATCH_DOUBT_PROVIDER_IDLE] ])('records released doubt when %s', async (_label, ending, reason) => { const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(SESSION, null)) + ).resolves.toMatchObject({ ok: true }) const child = claude.child(SESSION) @@ -165,7 +170,9 @@ describe('a live Claude chat whose CLI took a send and let it go without an echo it('holds a send it has only queued, through an idle that may precede its start', async () => { const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(SESSION, null)) + ).resolves.toMatchObject({ ok: true }) const child = claude.child(SESSION) diff --git a/src/main/runtime/claude-structured-resumed-start-failure.test.ts b/src/main/runtime/claude-structured-resumed-start-failure.test.ts index d3ba9c2fa17c..1a32adaa1954 100644 --- a/src/main/runtime/claude-structured-resumed-start-failure.test.ts +++ b/src/main/runtime/claude-structured-resumed-start-failure.test.ts @@ -11,6 +11,7 @@ import { createScriptedClaudeRuntime, scriptedClaudeExitError } from './structured-claude-scripted-runtime-test-support' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' const SESSION = 'claude-resumed-start' const CALLER = { callerKey: 'client-1' } @@ -42,7 +43,9 @@ function rejectionTexts(events: AgentSessionSubscribeEvent[]): string[] { describe('a reopened Claude chat whose CLI dies before initialize', () => { it('publishes the startup failure, with the diagnostic, to the open chat', async () => { const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(SESSION, null)) + ).resolves.toMatchObject({ ok: true }) await waitForStructuredAgentSessionRecovery() diff --git a/src/main/runtime/claude-structured-send-held-for-startup.test.ts b/src/main/runtime/claude-structured-send-held-for-startup.test.ts index 0ded8c9a6f2b..2264751778c0 100644 --- a/src/main/runtime/claude-structured-send-held-for-startup.test.ts +++ b/src/main/runtime/claude-structured-send-held-for-startup.test.ts @@ -14,6 +14,7 @@ import { createScriptedClaudeRuntime, scriptedClaudeExitError } from './structured-claude-scripted-runtime-test-support' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' const SESSION = 'claude-send-held' const CALLER = { callerKey: 'client-1' } @@ -82,7 +83,9 @@ describe('a send into a Claude chat whose CLI keeps failing at startup', () => { it('restarts once, rejects the held message with the diagnostic when that start dies too, then delivers a new one once the CLI is healthy', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(SESSION, null)) + ).resolves.toMatchObject({ ok: true }) await failLatestStart(host, 1) @@ -132,7 +135,7 @@ describe('a send while the first Claude start is still answering initialize', () it('is queued, and written once the CLI proves its start', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() - await host.attach(CALLER, claude.attachParams(SESSION, null)) + await attachForTests(host, CALLER, claude.attachParams(SESSION, null)) await send(host, 'hello') expect(claude.child(SESSION).calls).not.toContain('send') @@ -148,7 +151,7 @@ describe('a send while the first Claude start is still answering initialize', () it('is rejected with the diagnostic when the CLI dies first, and restarts nothing', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() - await host.attach(CALLER, claude.attachParams(SESSION, null)) + await attachForTests(host, CALLER, claude.attachParams(SESSION, null)) const startedFence = fence(host) const held = await send(host, 'hello') diff --git a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts index c29aaa2a37e1..9bc6377663af 100644 --- a/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts +++ b/src/main/runtime/claude-structured-send-restart-dies-before-dispatch.test.ts @@ -15,6 +15,7 @@ import { createScriptedClaudeRuntime, scriptedClaudeExitError } from './structured-claude-scripted-runtime-test-support' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' const SESSION = 'claude-send-restart-dies-first' const CALLER = { callerKey: 'client-1' } @@ -134,7 +135,9 @@ describe('a send whose restarted Claude child dies before it proves its start', it('is rejected with the diagnostic, adds no row, and a new send is one new attempt', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(SESSION, null)) + ).resolves.toMatchObject({ ok: true }) await failLatestStart(host, 1) @@ -175,7 +178,9 @@ describe('a send whose restarted Claude child dies before it proves its start', async (at) => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(SESSION, null)) + ).resolves.toMatchObject({ ok: true }) await failLatestStart(host, 1) @@ -199,7 +204,9 @@ describe('a send whose restarted Claude child dies before it proves its start', it('reaches a subscriber that was open across the restart and the exit', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(SESSION, null)) + ).resolves.toMatchObject({ ok: true }) const events: AgentSessionSubscribeEvent[] = [] @@ -253,7 +260,9 @@ describe('a chat whose Claude CLI keeps failing to start, seen by a subscriber o claude.behave(SESSION, { initHangs: true }) const host = await claude.install() const events: AgentSessionSubscribeEvent[] = [] - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(SESSION, null)) + ).resolves.toMatchObject({ ok: true }) const unsubscribe = await host.subscribe({ diff --git a/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts index e06cfc5d4519..5e7f17de68d4 100644 --- a/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts +++ b/src/main/runtime/claude-structured-startup-fault-is-not-an-exit.test.ts @@ -12,6 +12,7 @@ import { createScriptedClaudeRuntime, scriptedClaudeExitError } from './structured-claude-scripted-runtime-test-support' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' const SESSION = 'claude-startup-fault' const CALLER = { callerKey: 'client-1' } @@ -79,7 +80,8 @@ describe('a Claude start that Orca fails while the CLI is still running', () => claude.behave(SESSION, { optionWritesFail: true }) const host = await claude.install() await expect( - host.attach( + attachForTests( + host, CALLER, claude.attachParams(SESSION, null, { options: { permissionMode: 'plan' } }) ) @@ -94,7 +96,9 @@ describe('a Claude start that Orca fails while the CLI is still running', () => it('records on a held message a start that could not happen when init names another session', async () => { claude.behave(SESSION, { initHangs: true, initNamesForeignSession: true }) const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(SESSION, null)) + ).resolves.toMatchObject({ ok: true }) const held = await send(host, 'hello') @@ -117,7 +121,9 @@ describe('a Claude start that Orca fails while the CLI is still running', () => it('still says Claude stopped when the CLI exits on its own before its start lands', async () => { claude.behave(SESSION, { initHangs: true }) const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(SESSION, null)) + ).resolves.toMatchObject({ ok: true }) const held = await send(host, 'hello') diff --git a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts index 56ba00618836..2f7095561439 100644 --- a/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts +++ b/src/main/runtime/claude-structured-startup-unanswered-control-request.test.ts @@ -12,6 +12,7 @@ import { claudeSessionIdForOrcaSession } from '../claude/claude-structured-launc import { hostTestMessage } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' const SESSION = 'claude-startup-unanswered-control' const CALLER = { callerKey: 'client-1' } @@ -111,7 +112,7 @@ describe('a Claude start whose CLI answers initialize but not a control request' const host = await claude.install() const saved = { model: 'sonnet', permissionMode: 'plan' } await expect( - host.attach(CALLER, claude.attachParams(SESSION, null, { options: saved })) + attachForTests(host, CALLER, claude.attachParams(SESSION, null, { options: saved })) ).resolves.toMatchObject({ ok: true }) // The restore asked; the CLI never answered; startup went on without it. @@ -140,7 +141,11 @@ describe('a Claude start whose CLI answers initialize but not a control request' vi.spyOn(console, 'warn').mockImplementation(() => {}) const host = await claude.install() await expect( - host.attach(CALLER, claude.attachParams(SESSION, null, { options: { model: 'sonnet' } })) + attachForTests( + host, + CALLER, + claude.attachParams(SESSION, null, { options: { model: 'sonnet' } }) + ) ).resolves.toMatchObject({ ok: true }) await vi.waitFor( () => expect(record(host)?.options).toEqual({ model: 'sonnet', effort: 'high' }), @@ -163,7 +168,11 @@ describe('a Claude start whose CLI answers initialize but not a control request' vi.spyOn(console, 'warn').mockImplementation(() => {}) const host = await claude.install() await expect( - host.attach(CALLER, claude.attachParams(SESSION, null, { options: { model: 'sonnet' } })) + attachForTests( + host, + CALLER, + claude.attachParams(SESSION, null, { options: { model: 'sonnet' } }) + ) ).resolves.toMatchObject({ ok: true }) await vi.waitFor( () => expect(record(host)?.options).toEqual({ model: 'sonnet', effort: 'high' }), @@ -231,7 +240,11 @@ describe('a Claude start whose CLI answers initialize but not a control request' vi.spyOn(console, 'warn').mockImplementation(() => {}) const host = await claude.install() await expect( - host.attach(CALLER, claude.attachParams(SESSION, null, { options: { model: 'sonnet' } })) + attachForTests( + host, + CALLER, + claude.attachParams(SESSION, null, { options: { model: 'sonnet' } }) + ) ).resolves.toMatchObject({ ok: true }) // The record holds the saved model from creation; only the start's own report (effort) says // startup finished, and an option write before then is refused as still starting. @@ -250,7 +263,9 @@ describe('a Claude start whose CLI answers initialize but not a control request' it("lands with effort unknown when startup's own settings read goes unanswered", async () => { claude.behave(SESSION, { startupSettingsReadHangs: true, controlTimeoutMs: DEADLINE_MS }) const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(SESSION, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(SESSION, null)) + ).resolves.toMatchObject({ ok: true }) diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index c0d4c596ffae..c67d5580ee03 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -215,8 +215,8 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR ...(input.resumeFrom && adoption ? { // `adopt` is what makes the reservation seed the handle chain. Presence of - // `providerHandle` alone must not: `agentSession.ensure` already passes one today - // without adopting anything. + // `providerHandle` alone must not: an agent start passes one without adopting + // anything. adopt: { providerHandle: input.agent === 'claude' 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 a5a79b6be153..f31999cb3b61 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -13,7 +13,10 @@ import { randomUUID } from 'node:crypto' import { isDefinitiveAgentSessionCreateRefusal } from '../../../../shared/agent-session-definitive-refusal' -import type { AgentJournalMessageItem } from '../../../../shared/agent-session-journal-types' +import type { + AgentJournalMessageItem, + AgentJournalStartFailure +} from '../../../../shared/agent-session-journal-types' import { ORCHESTRATION_READINESS_TIMEOUT_MS } from '../../../../shared/orchestration-timing-budgets' import { agentSessionSendSubmission } from '../../../../shared/agent-session-wire' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' @@ -76,8 +79,8 @@ export async function createStructuredWorkerSession(args: { onJournalActivity: (sessionId: string) => void }): Promise<{ identity: StructuredWorkerIdentity; host: StructuredAgentSessionHost }> { const sessionId = randomUUID() - // Registered BEFORE the session is created, because `attach` is what spawns the provider child - // and the child's environment is read from this registry at spawn time. Registering afterwards + // Registered BEFORE the session is created, because the child's environment is read from this + // registry when its first message (the preamble) starts it. Registering afterwards // ships a worker with no ORCA_TERMINAL_HANDLE, whose bare `orca orchestration check` then // resolves to whatever single leaf sits in the worktree — by default the COORDINATOR's pane. // @@ -213,14 +216,20 @@ type StructuredWorkerPreambleHost = Pick< deps: { store: { getRecord: (sessionId: string) => { lease: { runtimeFence: number } } | null } } } -/** Delivers the dispatch preamble as the worker's first turn. `pending`: the worker's agent had - * not taken it within the wait; the host still holds it for that agent, and never re-sends it. */ +/** What became of the preamble within the wait. `pending`: the worker's agent had not taken it; the + * host still holds it for that agent and never re-sends it. `startFailure`: its start failed and + * another is booked, which is why it had not. */ +export type StructuredWorkerPreambleDelivery = + | { state: 'accepted' } + | { state: 'pending'; startFailure?: AgentJournalStartFailure } + +/** Delivers the dispatch preamble as the worker's first turn, which is what starts its agent. */ export async function sendStructuredWorkerPreamble(args: { host: StructuredWorkerPreambleHost sessionId: string dispatchId: string preamble: string -}): Promise<'accepted' | 'pending'> { +}): Promise { const body: AgentJournalMessageItem = { kind: 'message', role: 'user', @@ -259,8 +268,14 @@ export async function sendStructuredWorkerPreamble(args: { )?.value ) ?? answered) : answered - if (submission?.dispatchState === 'accepted' || submission?.dispatchState === 'pending') { - return submission.dispatchState + if (submission?.dispatchState === 'accepted') { + return { state: 'accepted' } + } + if (submission?.dispatchState === 'pending') { + return { + state: 'pending', + ...(submission.startFailure ? { startFailure: submission.startFailure } : {}) + } } if (submission?.dispatchState === 'rejected') { // A rejection is a verdict, not a mystery: the preamble provably did not happen. diff --git a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts index 0d80079c13d8..a18a90080b56 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts @@ -56,14 +56,17 @@ export async function deliverWorkerDispatchPreamble(args: { }) return { structuredTurnStart: - delivery === 'accepted' + delivery.state === 'accepted' ? { verdict: 'observed' } : { verdict: 'unobserved', - reason: - 'The dispatch preamble was accepted, but the agent had not started to take it. It ' + - 'is delivered when the agent starts; if the worker then reports, this Dispatch ' + - 'settles normally.' + reason: delivery.startFailure + ? `The worker's agent did not start: ${delivery.startFailure.reason} The dispatch ` + + 'preamble waits for its next start; if the worker then reports, this Dispatch ' + + 'settles normally.' + : 'The dispatch preamble was accepted, but the agent had not started to take it. ' + + 'It is delivered when the agent starts; if the worker then reports, this ' + + 'Dispatch settles normally.' } } } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index 9cbad804cd50..876dbd9d9ad4 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -32,6 +32,7 @@ import { openTestJournalHostDatabase, updateTestJournalRowJson } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' +import { attachForTests } from '../../../native-chat/agent-session-wire/structured-agent-session-attach-test-support' const CLIENT = { clientId: 'device-1', @@ -151,7 +152,7 @@ describe('opening a chat at rest (P2-01)', () => { describe('the accessor', () => { it('opens a closed conversation once, however many readers arrive together (P2-02)', async () => { // Written by the provider alone, so nothing but the readers below ever opens it here. - const attached = await rig.host.attach(CALLER, hostTestAttachParams(null)) + const attached = await attachForTests(rig.host, CALLER, hostTestAttachParams(null)) expect(attached.ok).toBe(true) rig.adapter.acquire.mock.calls .at(-1)?.[0] 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 0c3b7b087133..064e1cfb827f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create.ts @@ -1,15 +1,16 @@ /** - * Creating a structured session for a worktree: resolve the create intent, attach it under the - * host-computed fingerprint, then publish its tab. + * Creating a structured session for a worktree: resolve the create intent, create it at rest under + * the host-computed fingerprint, then publish its tab. Nothing starts an agent here; the chat's + * first message does. * * Extracted from `agentSession.create` so orchestration can start a native-born structured worker * on exactly the same path. `activate` is the only knob the two callers differ on: a chat the user * asked for takes the surface, a background dispatch must not steal it (the terminal worker path's * `surfaceOwner: false`). * - * The prepare/commit split is the pre-commit boundary, not a style choice: nothing before `attach` - * commits a session, so that span answers with a refusal, and nothing after it may be folded back - * in. Both callers run the same two halves, so orchestration gets that guarantee too. + * The prepare/commit split is the pre-commit boundary, not a style choice: nothing before the + * host's `create` commits a session, so that span answers with a refusal, and nothing after it may + * be folded back in. Both callers run the same two halves, so orchestration gets that guarantee too. */ import { refuse } from '../../../../shared/agent-session-wire-refusals' @@ -120,7 +121,7 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: { } } -/** The commit half. Past `attach`, a failure no longer proves the session does not exist. */ +/** The commit half. Past the host's `create`, a failure no longer proves the session does not exist. */ export async function commitStructuredAgentSessionCreate(args: { runtime: OrcaRuntimeService caller: StructuredAgentSessionCaller @@ -128,7 +129,7 @@ export async function commitStructuredAgentSessionCreate(args: { activate: boolean }): Promise> { const { prepared } = args - const result = await prepared.host.attach(args.caller, prepared.attachParams) + const result = await prepared.host.create(args.caller, prepared.attachParams) if (!result.ok || !prepared.tab) { return result } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts index 45c01c2c63ac..d8279a9d9103 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts @@ -28,6 +28,7 @@ import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { agentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' +import { attachForTests } from '../../../native-chat/agent-session-wire/structured-agent-session-attach-test-support' const CONNECTION = 'connection-1' const CLIENT = { @@ -110,7 +111,9 @@ beforeEach(async () => { > ) dispatcher = new RpcDispatcher({ runtime, methods: STRUCTURED_AGENT_SESSION_METHODS }) - expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({ + expect( + await attachForTests(host, { callerKey: 'client-1' }, hostTestAttachParams(null)) + ).toMatchObject({ ok: true }) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts index 903f8d440afb..084ecb5d6847 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-reveal.ts @@ -5,7 +5,7 @@ // tab, and a client drops every unpublished `agent-session` tab on each session-tabs sync. Without // this the row is unreachable, and the client's "retry in a moment" is advice that never comes true. // -// Distinct from `agentSession.ensure`, which attaches a location the CLIENT supplies. Here the host +// Distinct from an attach-shaped create, whose location the CLIENT supplies. Here the host // reads its own record and answers with that record's workspace and provider, so a client knowing // only a session id cannot aim the publication somewhere else. diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index b97da008a402..0af547a37ee4 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -69,7 +69,7 @@ import { import { sendStructuredAgentSessionForClient } from './structured-agent-session-send-compatibility' /** - * The attach-shaped entries take the location from the client instead of resolving it from a + * An attach-shaped create takes the location from the client instead of resolving it from a * worktree, so they never reach the worktree-resolving create-support check. Ask the executing * host the same question directly: the answer includes host-measured facts the client cannot see * or forge, such as whether this machine can read a provider child's process start time. @@ -91,14 +91,6 @@ async function resolveClientSuppliedAttach(params: z.infer, return { host, attachParams } } -async function attachClientSuppliedLocation( - params: z.infer, - ctx: RpcContext -): Promise { - const { host, attachParams } = await resolveClientSuppliedAttach(params, ctx) - return host.attach(callerFor(ctx), attachParams) -} - export const STRUCTURED_AGENT_SESSION_METHODS = [ defineMethod({ name: 'agentSession.rewind', @@ -191,11 +183,6 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ }) } }), - defineMethod({ - name: 'agentSession.ensure', - params: AttachParams, - handler: async (params, ctx) => attachClientSuppliedLocation(params, ctx) - }), defineMethod({ name: 'agentSession.send', params: SendParams, diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts index f437e8068990..0f79563fc0d0 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts @@ -38,6 +38,7 @@ import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { commitStructuredAgentSessionCreate } from './structured-agent-session-create' import { closeStructuredAgentSessionChild } from '../../structured-agent-session-close' import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' +import { attachForTests } from '../../../native-chat/agent-session-wire/structured-agent-session-attach-test-support' const WORKTREE = `id:${HOST_TEST_LOCATION.workspaceId}` const SOURCE_TAB = `structured-agent-session-${HOST_TEST_SESSION}` @@ -485,7 +486,8 @@ describe('a create that reserves its tab', () => { }) it('restores no tab for a reserved create that stopped before its tab was published', async () => { - const attached = await host.attach( + const attached = await attachForTests( + host, caller, hostTestAttachParams(null, { envelope: { diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts index d544dc6720b0..bc07ddd03d2e 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts @@ -214,7 +214,6 @@ export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'session.tabs.unsubscribeAll', 'agentSession.createSupport', 'agentSession.create', - 'agentSession.ensure', 'agentSession.reveal', 'agentSession.send', 'agentSession.cancel', diff --git a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts index facd5e15b05b..1e1cfc15eea3 100644 --- a/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts +++ b/src/main/runtime/structured-agent-session-codex-turn-end-settlement.test.ts @@ -34,6 +34,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' // The turns a send or Stop is waiting on to open, so a test knows the wait began. const openWaits = vi.hoisted(() => { @@ -229,7 +230,7 @@ beforeEach(async () => { }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) attachParams.envelope.clientOperationId = operationId() - const attached = await host.attach(CALLER, attachParams) + const attached = await attachForTests(host, CALLER, attachParams) if (!attached.ok) { throw new Error(JSON.stringify(attached.refusal)) } diff --git a/src/main/runtime/structured-agent-session-runtime-exit.test.ts b/src/main/runtime/structured-agent-session-runtime-exit.test.ts index 3da0c6f203d2..81024a552cc1 100644 --- a/src/main/runtime/structured-agent-session-runtime-exit.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-exit.test.ts @@ -17,6 +17,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' describe('structured session runtime provider-exit wiring', () => { let root: string | null = null @@ -92,7 +93,7 @@ describe('structured session runtime provider-exit wiring', () => { }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) attachParams.envelope.clientOperationId = operationId() - const attached = await host.attach({ callerKey: 'runtime-test' }, attachParams) + const attached = await attachForTests(host, { callerKey: 'runtime-test' }, attachParams) if (!attached.ok) { throw new Error( JSON.stringify({ refusal: attached.refusal, connections: connections.length }) @@ -196,7 +197,7 @@ describe('structured session runtime provider-exit wiring', () => { }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) attachParams.envelope.clientOperationId = operationId() - const attached = await host.attach({ callerKey: 'runtime-test' }, attachParams) + const attached = await attachForTests(host, { callerKey: 'runtime-test' }, attachParams) if (!attached.ok) { throw new Error( JSON.stringify({ refusal: attached.refusal, connections: connections.length }) @@ -300,7 +301,7 @@ describe('structured session runtime provider-exit wiring', () => { }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) attachParams.envelope.clientOperationId = operationId() - const attached = await host.attach({ callerKey: 'runtime-test' }, attachParams) + const attached = await attachForTests(host, { callerKey: 'runtime-test' }, attachParams) expect(attached.ok).toBe(true) connections[0]?.handlers.onExit?.(new Error('the first child exited')) await vi.waitFor(() => @@ -406,7 +407,7 @@ describe('structured session runtime provider-exit wiring', () => { }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) attachParams.envelope.clientOperationId = operationId() - expect(await host.attach({ callerKey: 'runtime-test' }, attachParams)).toMatchObject({ + expect(await attachForTests(host, { callerKey: 'runtime-test' }, attachParams)).toMatchObject({ ok: true }) diff --git a/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts b/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts index 9bce63de54da..5bfd795a8dc5 100644 --- a/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts +++ b/src/main/runtime/structured-agent-session-runtime-provider-started.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { waitForStructuredAgentSessionRecovery } from './structured-agent-session-runtime' import { createScriptedClaudeRuntime } from './structured-claude-scripted-runtime-test-support' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' const STALLED = 'claude-started-stalled' const HEALTHY = 'claude-started-healthy' @@ -22,13 +23,17 @@ describe('a Claude child proving its start', () => { claude.behave(STALLED, { stallsControlReads: true }) const host = await claude.install() - await expect(host.attach(CALLER, claude.attachParams(HEALTHY, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(HEALTHY, null)) + ).resolves.toMatchObject({ ok: true }) await waitForStructuredAgentSessionRecovery() // This child answers startup, then never answers another control read. - await expect(host.attach(CALLER, claude.attachParams(STALLED, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(STALLED, null)) + ).resolves.toMatchObject({ ok: true }) await vi.waitFor(() => expect(claude.child(STALLED).calls).toContain('get_settings')) @@ -60,13 +65,15 @@ describe('a Claude child proving its start', () => { const host = await claude.install() // This start never returns from its spawn, so its lease stays reserved with no child. claude.behave(HEALTHY, { spawnHangs: true }) - void host.attach(CALLER, claude.attachParams(HEALTHY, null)).catch(() => undefined) + void attachForTests(host, CALLER, claude.attachParams(HEALTHY, null)).catch(() => undefined) await vi.waitFor(() => expect(host.deps.store.getRecord(HEALTHY)?.lease.claimStatus).toBe('reserved') ) const childrenWhileHung = claude.children(HEALTHY).length - await expect(host.attach(CALLER, claude.attachParams(STALLED, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(STALLED, null)) + ).resolves.toMatchObject({ ok: true }) await vi.waitFor(() => @@ -94,7 +101,9 @@ describe('a Claude child proving its start', () => { await writeHeld return replaceSessionOptions(input) }) - await expect(host.attach(CALLER, claude.attachParams(HEALTHY, null))).resolves.toMatchObject({ + await expect( + attachForTests(host, CALLER, claude.attachParams(HEALTHY, null)) + ).resolves.toMatchObject({ ok: true }) await vi.waitFor(() => expect(writing).toBe(true)) diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index cb4b415498ff..e16ba68adfbf 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -42,6 +42,7 @@ import { resetProviderFaults, type FakeConnection } from './structured-chat-coordinator-fake-codex-fixture' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' const COORDINATOR = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' const PEER_CHAT = '7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64' @@ -88,7 +89,11 @@ async function call( } async function openChat(sessionId: string): Promise { - const attached = await host.attach({ callerKey: 'test-surface' }, attachParams(sessionId)) + const attached = await attachForTests( + host, + { callerKey: 'test-surface' }, + attachParams(sessionId) + ) expect(attached, JSON.stringify(attached)).toMatchObject({ ok: true }) await host.setSessionTabVisibility(sessionId, true) threadBySession.set(sessionId, codex.connections.at(-1)!.threadId!) diff --git a/src/main/runtime/structured-child-records-switch.test.ts b/src/main/runtime/structured-child-records-switch.test.ts index ea3559e30592..a61cdc57726f 100644 --- a/src/main/runtime/structured-child-records-switch.test.ts +++ b/src/main/runtime/structured-child-records-switch.test.ts @@ -33,6 +33,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' vi.mock('../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() })) @@ -130,7 +131,7 @@ describe('the chat strip and the session list read the same host child records', }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) attachParams.envelope.clientOperationId = `${Date.now()}-${'1'.padStart(32, '0')}` - const attached = await host.attach({ callerKey: 'switch-test' }, attachParams) + const attached = await attachForTests(host, { callerKey: 'switch-test' }, attachParams) expect(attached).toMatchObject({ ok: true }) const fence = attached.ok ? attached.value.fence : 0 const summaries: AgentSessionStatusSummary[] = [] diff --git a/src/main/runtime/structured-claude-pending-rewind.test.ts b/src/main/runtime/structured-claude-pending-rewind.test.ts index c521ef8b6d95..8782440013d8 100644 --- a/src/main/runtime/structured-claude-pending-rewind.test.ts +++ b/src/main/runtime/structured-claude-pending-rewind.test.ts @@ -24,6 +24,7 @@ import type { AgentSessionRecordStore } from './agent-session-record-store' import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' import { createStructuredClaudeRuntimeAdapter } from './structured-claude-runtime-adapter' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' const caller = { callerKey: 'desktop' } const PROVIDER_SESSION_ID = claudeSessionIdForOrcaSession(HOST_TEST_SESSION) @@ -101,7 +102,7 @@ async function seedPendingRewind(phase: 'prepared' | 'provider-succeeded') { async function reattach() { await host.close(HOST_TEST_SESSION, 'evict') - expect(await host.attach(caller, attachParams(fence()))).toMatchObject({ ok: true }) + expect(await attachForTests(host, caller, attachParams(fence()))).toMatchObject({ ok: true }) } beforeEach(async () => { @@ -129,7 +130,7 @@ beforeEach(async () => { now: () => HOST_TEST_NOW, probeOwner: async () => ({ outcome: 'exit-observed' }) }) - expect(await host.attach(caller, attachParams(null))).toMatchObject({ ok: true }) + expect(await attachForTests(host, caller, attachParams(null))).toMatchObject({ ok: true }) }) afterEach(async () => { diff --git a/src/main/runtime/structured-codex-child-work-runtime.test.ts b/src/main/runtime/structured-codex-child-work-runtime.test.ts index d03ab6f1be5d..7be4ae55b963 100644 --- a/src/main/runtime/structured-codex-child-work-runtime.test.ts +++ b/src/main/runtime/structured-codex-child-work-runtime.test.ts @@ -19,6 +19,7 @@ import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' const THREAD = 'thread-runtime-child-work' const CHILD = 'thread-runtime-reviewer' @@ -87,7 +88,7 @@ describe('structured Codex child work through the production runtime', () => { }) const attachParams = hostTestAttachParams(null, { providerHandle: undefined }) attachParams.envelope.clientOperationId = `${Date.now()}-${'1'.padStart(32, '0')}` - const attached = await host.attach({ callerKey: 'runtime-test' }, attachParams) + const attached = await attachForTests(host, { callerKey: 'runtime-test' }, attachParams) expect(attached).toMatchObject({ ok: true }) // Creating the session starts its child; nothing else has to keep it running. expect(connections).toHaveLength(1) diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 87eeae770246..8d4a2bba25a7 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -138,7 +138,7 @@ import { TypeText } from './computer-schemas-params' import { - AttachParams as AttachParamsOfEmulatorParams, + AttachParams, AxParams, ButtonParams, EmulatorAvailabilityParams, @@ -464,7 +464,6 @@ import { } from './speech-params' import { SshTarget } from './ssh-params' import { - AttachParams, CancelParams, ConversationCommandParams, CreateParams, @@ -576,7 +575,6 @@ export const RPC_PARAMS_BY_METHOD = { 'agentSession.conversationOutline': OptionsParams, 'agentSession.create': CreateParams, 'agentSession.createSupport': CreateSupportParams, - 'agentSession.ensure': AttachParams, 'agentSession.handoffStatus': HandoffStatusParams, 'agentSession.history': HistoryParams, 'agentSession.hold': HoldParams, @@ -735,7 +733,7 @@ export const RPC_PARAMS_BY_METHOD = { 'computer.setValue': SetValue, 'computer.typeText': TypeText, 'diagnostics.memory': null, - 'emulator.attach': AttachParamsOfEmulatorParams, + 'emulator.attach': AttachParams, 'emulator.availability': EmulatorAvailabilityParams, 'emulator.ax': AxParams, 'emulator.button': ButtonParams, From b1bb116de100bab8184459f731e54c62024aacbd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:43:07 -0700 Subject: [PATCH 02/84] test(native-chat): tests for a chat created at rest and started by its first message --- ...tured-agent-session-adopted-import.test.ts | 145 +++++----- ...structured-agent-session-create-at-rest.ts | 38 +-- ...ude-structured-session-integration.test.ts | 259 +++++++++--------- src/main/runtime/mobile-rpc-allowlist.test.ts | 1 - ...stration-structured-worker-session.test.ts | 22 +- .../orchestration-worker-mode-opacity.test.ts | 2 +- ...ration-worker-start-mode-selection.test.ts | 2 +- ...ured-agent-session-adoption-replay.test.ts | 5 +- ...ession-gate-classification.test-fixture.ts | 1 - ...ed-agent-session-precommit-refusal.test.ts | 20 +- ...ructured-agent-session-rpc.test-fixture.ts | 2 +- .../methods/structured-agent-session.test.ts | 42 +-- .../methods/structured-chat-tab-table.test.ts | 12 +- ...d-agent-session-integration-replay.test.ts | 34 ++- ...ructured-agent-session-integration.test.ts | 103 ++++--- ...ss-version-agent-session-wire.unit.test.ts | 28 +- .../structured-agent-session-host-fixture.ts | 2 +- ...ructured-agent-session-surface-manifest.ts | 9 +- ...-chat-owner-status-activation.unit.test.ts | 5 +- 19 files changed, 370 insertions(+), 362 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index 1eab96a21d2b..2ccdc2e901ff 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -1,5 +1,6 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -// Source validation must finish before a new session claims the provider conversation. +// Adoption happens at the at-rest create: the source is validated before a record claims the provider +// conversation, and no agent starts. import { mkdtemp, rm, writeFile, truncate } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -13,8 +14,6 @@ import { attachFingerprintFields, type AgentSessionAttachParams } from './structured-agent-session-attach' -import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' -import { performAttach, type AttachFlowInput } from './structured-agent-session-attach-flow' import { AgentSessionJournal } from '../agent-session-journal/journal-store' import * as legacyImport from '../agent-session-journal/journal-legacy-import' import { StructuredAgentSessionHost } from './structured-agent-session-host' @@ -26,8 +25,11 @@ const THREAD = 'adopted-thread' const OPERATION = `${NOW}-${'1'.padStart(32, '0')}` let root: string | null = null let store: AgentSessionRecordStore | null = null +let host: StructuredAgentSessionHost | null = null afterEach(async () => { + await host?.flushAllStreamedEvents() + host = null if (root) { await rm(root, { recursive: true, force: true }) } @@ -106,7 +108,6 @@ function adapter(): StructuredAgentSessionAdapter { observedAt: NOW } })), - // Proven released, so the failure rethrows its own cause rather than an unproven-exit wrapper. releaseAcquisition: vi.fn(async () => true), dispatch: vi.fn(), cancelTurn: vi.fn(), @@ -115,27 +116,25 @@ function adapter(): StructuredAgentSessionAdapter { } } -async function attach( - transcriptPath: string | undefined, - sessionAdapter: StructuredAgentSessionAdapter, - onAttached: AttachFlowInput['onAttached'] = () => {} -) { +async function openHost(sessionAdapter: StructuredAgentSessionAdapter) { store ??= await openTestAgentSessionRecordStore(root!) - return performAttach({ + host ??= new StructuredAgentSessionHost({ store, adapter: sessionAdapter, - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - params: attachParams(transcriptPath), - now: () => NOW, - onAttached + journalDatabase: openTestJournalHostDatabase(root!), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW }) + return host +} + +async function create( + transcriptPath: string | undefined, + sessionAdapter: StructuredAgentSessionAdapter +) { + const opened = await openHost(sessionAdapter) + return opened.create({ callerKey: 'client-1' }, attachParams(transcriptPath)) } describe('adopting a provider conversation on create', () => { @@ -145,13 +144,19 @@ describe('adopting a provider conversation on create', () => { await writeCodexRollout(transcriptPath, 'token ORCA-ADOPT-1') const sessionAdapter = adapter() - const result = await attach(transcriptPath, sessionAdapter) + const result = await create(transcriptPath, sessionAdapter) expect(result).toMatchObject({ ok: true }) - // The adapter was asked to resume, not to start: the seeded chain is what tells it which - // conversation this session owns. + // The seeded chain is what tells the first start which conversation to resume. + expect(store?.getRecord(SESSION)?.providerHandleChain).toEqual([ + expect.objectContaining({ + origin: 'adopted', + handle: { provider: 'codex', threadId: THREAD } + }) + ]) const page = (result as { value: { page: { items: unknown[] } } }).value.page expect(JSON.stringify(page.items)).toContain('ORCA-ADOPT-1') + expect(sessionAdapter.acquire).not.toHaveBeenCalled() }) it('replays create without replacing journal-only messages or rereading the source', async () => { @@ -159,26 +164,26 @@ describe('adopting a provider conversation on create', () => { const transcriptPath = join(root, 'rollout.jsonl') await writeCodexRollout(transcriptPath, 'original turn') const sessionAdapter = adapter() - const first = await attach(transcriptPath, sessionAdapter, async ({ journal }) => { - await journal.appendItem( - { provider: 'legacy', agent: 'codex', sessionId: THREAD, recordId: 'journal-only' }, - { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'not yet in rollout' }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journal.close() - }) + const first = await create(transcriptPath, sessionAdapter) expect(first.ok).toBe(true) - await rm(transcriptPath) - const replay = await attach(transcriptPath, sessionAdapter, async ({ journal }) => - journal.close() + const journal = host?.collaboratorsForTests().sessions.get(SESSION)?.journal + if (!journal) { + throw new Error('no open journal') + } + await journal.appendItem( + { provider: 'legacy', agent: 'codex', sessionId: THREAD, recordId: 'journal-only' }, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'not yet in rollout' }] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) + await rm(transcriptPath) + const replay = await create(transcriptPath, sessionAdapter) expect(replay).toMatchObject({ ok: true, replayed: true }) if (!first.ok || !replay.ok) { - throw new Error('attach failed') + throw new Error('create failed') } expect(replay.cursor.epoch).toBe(first.cursor.epoch) expect(JSON.stringify(replay.value.page.items)).toContain('not yet in rollout') - expect(sessionAdapter.acquire).toHaveBeenCalledTimes(1) + expect(sessionAdapter.acquire).not.toHaveBeenCalled() }) it.each(['missing', 'oversized', 'empty', 'invalid', 'source-less'] as const)( @@ -193,19 +198,15 @@ describe('adopting a provider conversation on create', () => { await writeFile(transcriptPath, kind === 'empty' ? '' : 'not json\n') } const sessionAdapter = adapter() - const onAttached = vi.fn() - const result = await attach( + const result = await create( kind === 'source-less' ? undefined : transcriptPath, - sessionAdapter, - onAttached + sessionAdapter ) expect(result).toMatchObject({ ok: false, refusal: { code: 'agent_session_identity_required' } }) expect(sessionAdapter.acquire).not.toHaveBeenCalled() - expect(sessionAdapter.releaseAcquisition).not.toHaveBeenCalled() - expect(onAttached).not.toHaveBeenCalled() expect(store?.getRecord(SESSION)).toBeNull() expect(store?.listOperationRows()).toEqual([]) if (kind === 'oversized') { @@ -214,7 +215,9 @@ describe('adopting a provider conversation on create', () => { } ) - it('still releases acquisition on an import write failure, and leaves the conversation open', async () => { + // The import runs after the at-rest record commits, so its failure rejects the create but the + // conversation stays open for the next send. + it('rejects a create whose import write fails, and leaves the conversation writable', async () => { root = await mkdtemp(join(tmpdir(), 'orca-adopt-write-failure-')) const transcriptPath = join(root, 'rollout.jsonl') await writeCodexRollout(transcriptPath, 'valid source') @@ -222,34 +225,13 @@ describe('adopting a provider conversation on create', () => { new Error('disk write failed') ) const sessionAdapter = adapter() - await expect(attach(transcriptPath, sessionAdapter)).rejects.toThrow('disk write failed') - expect(sessionAdapter.acquire).toHaveBeenCalledTimes(1) - expect(sessionAdapter.releaseAcquisition).toHaveBeenCalledTimes(1) - }) - - it('leaves the conversation writable when the import fails after acquiring', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-adopt-host-failure-')) - const transcriptPath = join(root, 'rollout.jsonl') - await writeCodexRollout(transcriptPath, 'valid source') - store = await openTestAgentSessionRecordStore(root) - const host = new StructuredAgentSessionHost({ - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) - vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems').mockRejectedValueOnce( - new Error('disk write failed') - ) - const attached = await host - .create({ callerKey: 'client-1' }, attachParams(transcriptPath)) - .catch(() => null) - expect(attached?.ok).not.toBe(true) + await expect(create(transcriptPath, sessionAdapter)).rejects.toThrow('disk write failed') + expect(sessionAdapter.acquire).not.toHaveBeenCalled() + // Settled as failed: a retry of the same create is told so, not handed an empty chat. + expect(await create(transcriptPath, sessionAdapter)).toMatchObject({ ok: false }) const body = { kind: 'message' as const, role: 'user' as const, blocks: [] } - const sent = await host.send( + const sent = await host!.send( { callerKey: 'client-1' }, { envelope: { @@ -265,31 +247,30 @@ describe('adopting a provider conversation on create', () => { body } ) - // The failed attach kept the conversation's own journal open, so the send is recorded. expect(sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) - await host.flushAllStreamedEvents() }) - it('prepares a valid source once before acquisition and imports those exact items', async () => { + it('prepares a valid source once before the record claims it and imports those exact items', async () => { root = await mkdtemp(join(tmpdir(), 'orca-adopt-once-')) const transcriptPath = join(root, 'rollout.jsonl') - await writeCodexRollout(transcriptPath, 'prepared before acquiring') + await writeCodexRollout(transcriptPath, 'prepared before claiming') const prepare = vi.spyOn(legacyImport, 'prepareLegacyTranscriptImport') const sessionAdapter = adapter() - const acquire = sessionAdapter.acquire - sessionAdapter.acquire = vi.fn(async (input) => { + const opened = await openHost(sessionAdapter) + const createAtRest = opened.deps.store.createAtRest + vi.spyOn(opened.deps.store, 'createAtRest').mockImplementation(async (request) => { expect(prepare).toHaveBeenCalledTimes(1) + // Gone once the record claims it: the import must use what was prepared. await rm(transcriptPath) - return acquire(input) + return createAtRest(request) }) - const result = await attach(transcriptPath, sessionAdapter, async ({ journal }) => - journal.close() - ) + const result = await create(transcriptPath, sessionAdapter) expect(result.ok).toBe(true) if (!result.ok) { - throw new Error('attach failed') + throw new Error('create failed') } - expect(JSON.stringify(result.value.page.items)).toContain('prepared before acquiring') + expect(JSON.stringify(result.value.page.items)).toContain('prepared before claiming') expect(prepare).toHaveBeenCalledTimes(1) + expect(sessionAdapter.acquire).not.toHaveBeenCalled() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create-at-rest.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create-at-rest.ts index 2e1079408922..ad424027bb34 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create-at-rest.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create-at-rest.ts @@ -42,17 +42,6 @@ async function createAtRest( ): Promise { const { store, adapter } = context.deps const sessionId = params.envelope.sessionId - if (params.envelope.expectedRuntimeFence !== null) { - // A create names no fence; one that does is asking to take over an existing chat. - return { - ok: false, - refusal: refuse( - 'agent_session_operation_invalid', - { reason: 'requestMalformed' }, - 'A create cannot name a runtime fence.' - ) - } - } const admitted = admitAttachOrRefuse(params) if (!admitted.ok) { return admitted @@ -123,12 +112,27 @@ async function createAtRest( } const { journal } = conversation if (!created.replayed) { - await importAdoptedTranscript( - params, - { journal, unconfirmedClientMessageIds: [] }, - created.record, - transcript.items - ) + try { + await importAdoptedTranscript( + params, + { journal, unconfirmedClientMessageIds: [] }, + created.record, + transcript.items + ) + } catch (error) { + // Settled before rethrowing, so a retry of this create replays the failure, not a success. + await store.recordOperationOutcome({ + callerKey, + operationId: params.envelope.clientOperationId, + outcome: { + status: 'failed', + code: 'agent_session_operation_invalid', + details: { reason: 'attachFailed' }, + message: error instanceof Error ? error.message : String(error) + } + }) + throw error + } } const fence = created.record.lease.runtimeFence const tabId = store.getSessionTabId(created.record.sessionId) diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 39216d2e9df8..f2112e57c300 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -76,7 +76,7 @@ function createIntentParams() { return { envelope: envelope('agentSession.create', fields, null), ...fields } } -function ensureParams(fence: number) { +function resolvedCreateParams(fence: number) { const params = { location: { executionHostId: 'local', @@ -203,6 +203,37 @@ function textOf(item: AgentJournalRenderItem): string { : '' } +function message(text: string) { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } +} + +/** A message at the record's current fence; the chat's first one is what starts Claude. */ +async function send(text: string): Promise<{ + clientMessageId: string + submission: { dispatchState: string; reason: string | null } +}> { + const body = message(text) + return ok('agentSession.send', { + envelope: envelope('agentSession.send', { body }, leaseOf(SESSION).runtimeFence), + body + }) +} + +/** A created chat whose first message has started Claude. */ +async function startedChat(): Promise { + await ok<{ fence: number }>('agentSession.create', createIntentParams()) + await send('hello') + await vi.waitFor(() => expect(claude.connections).toHaveLength(1)) +} + +/** The submission a message ended as, read from a fresh subscription's snapshot. */ +async function submissionOf(clientMessageId: string) { + const snapshot = (await subscribe()).find((frame) => frame.type === 'snapshot') + return snapshot?.type === 'snapshot' + ? snapshot.page.submissions.find((entry) => entry.clientMessageId === clientMessageId) + : undefined +} + beforeEach(async () => { operations = 0 claudeAuthPolicy = { stripAuthEnv: false } @@ -227,7 +258,7 @@ beforeEach(async () => { getClientSettings: () => ({ experimentalStructuredNativeChat: true }), getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), resolveStructuredAgentSessionCreateIntent: async (input: { envelope: unknown }) => ({ - ...ensureParams(1), + ...resolvedCreateParams(1), envelope: input.envelope, providerHandle: undefined }), @@ -318,7 +349,7 @@ describe('a structured Claude session over agentSession.*', () => { CLAUDE_CONFIG_DIR: '/shell/claude' } - await ok<{ fence: number }>('agentSession.create', createIntentParams()) + await startedChat() const env = claude.live().launch.env expect(env).not.toHaveProperty('ANTHROPIC_API_KEY') @@ -336,7 +367,7 @@ describe('a structured Claude session over agentSession.*', () => { ANTHROPIC_API_KEY: 'sk-ant-SHELL-ONLY' } - await ok<{ fence: number }>('agentSession.create', createIntentParams()) + await startedChat() expect(claude.live().launch.env).toMatchObject({ CODEX_LB_API_KEY: 'shell-exported', @@ -350,7 +381,7 @@ describe('a structured Claude session over agentSession.*', () => { recordAccountHomePath = join(homedir(), '.claude') shellEnv = { ...shellEnv, CLAUDE_CONFIG_DIR: '/shell/claude', SHELL_ONLY_MARKER: 'from-shell' } - await ok<{ fence: number }>('agentSession.create', createIntentParams()) + await startedChat() const env = claude.live().launch.env expect(env).not.toHaveProperty('CLAUDE_CONFIG_DIR') @@ -362,111 +393,79 @@ describe('a structured Claude session over agentSession.*', () => { shellEnv = { ...shellEnv, CODEX_LB_API_KEY: 'shell-exported', LISTED_ONLY: 'yes' } shellEnvironmentPolicy = { inheritAll: false, names: ['LISTED_ONLY'] } - await ok<{ fence: number }>('agentSession.create', createIntentParams()) + await startedChat() const env = claude.live().launch.env expect(env?.LISTED_ONLY).toBe('yes') expect(env).not.toHaveProperty('CODEX_LB_API_KEY') }) - it('refuses a create whose configured env overrides the pinned managed account auth', async () => { + it('rejects the first message when the configured env overrides the pinned managed account auth', async () => { claudeAuthPolicy = { stripAuthEnv: true } // The default overlay carries ANTHROPIC_AUTH_TOKEN, which the terminal path // refuses at spawn-env.ts:25 rather than letting it beat the pinned account. - const params = createIntentParams() - const sentence = - 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.' - const refused = await call('agentSession.create', params) - - // The thrown answer keeps its wire code; only its words are the ones its replay reads. - expect(refused).toMatchObject({ - ok: false, - error: { code: 'runtime_error', message: sentence } - }) - // Its replay reads the same sentence, beside the situation it names. - expect(await call('agentSession.create', params)).toMatchObject({ - ok: true, - result: { - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - details: { reason: 'managedAccountEnvOverride' }, - message: sentence - } - } - }) + await ok<{ fence: number }>('agentSession.create', createIntentParams()) + + const sent = await send('hello') + + // Only the person can remove those variables, so the message ends at once with that sentence. + await vi.waitFor(async () => + expect(await submissionOf(sent.clientMessageId)).toMatchObject({ + dispatchState: 'rejected', + reason: + 'This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.', + rejection: { kind: 'managedAccountEnvOverride' } + }) + ) // Refused before spawn: no provider child was ever opened. expect(claude.connections).toHaveLength(0) }) - it('publishes, then ends the session quietly when initialization has no credentials', async () => { + it('rejects the first message, adding no row, when initialization has no credentials', async () => { claude.setInitializeAccount({ apiProvider: 'firstParty', tokenSource: 'none' }) - - // The create answers once the child is spawned; the missing credentials arrive after. await ok<{ fence: number }>('agentSession.create', createIntentParams()) - await waitForStructuredAgentSessionRecovery() - // No message waited on this start, so the empty chat says nothing. - expect(itemsOf(await subscribe()).filter((item) => item.body?.kind === 'status')).toEqual([]) - expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) - // A failed start is not auto-resumed into the same failure. - expect(claude.connections).toHaveLength(1) - - // The first message starts it again and says why; signing in is the person's to do, so it is - // not tried again on a timer and adds no row. - const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } - const sent = await ok<{ clientMessageId: string }>('agentSession.send', { - envelope: envelope('agentSession.send', { body }, leaseOf(SESSION).runtimeFence), - body - }) + // The first message starts Claude and says why it could not; signing in is the person's to do, + // so it is not tried again on a timer and adds no row. + const sent = await send('hello') await waitForStructuredAgentSessionRecovery() await vi.waitFor(async () => { - const frames = await subscribe() - const snapshot = frames.find((frame) => frame.type === 'snapshot') - expect( - snapshot?.type === 'snapshot' - ? snapshot.page.submissions.find( - (entry) => entry.clientMessageId === sent.clientMessageId - ) - : undefined - ).toMatchObject({ + expect(await submissionOf(sent.clientMessageId)).toMatchObject({ dispatchState: 'rejected', reason: 'Claude is not signed in for the selected account. Sign in, then send your message again.', rejection: { kind: 'notSignedIn' } }) - expect(itemsOf(frames).filter((item) => item.body?.kind === 'status')).toEqual([]) + expect(itemsOf(await subscribe()).filter((item) => item.body?.kind === 'status')).toEqual([]) }) - expect(claude.connections).toHaveLength(2) + expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) + // A failed start is not auto-resumed into the same failure. + expect(claude.connections).toHaveLength(1) }) // The root's death is first-hand. Its descendants were never snapshottable, or one was seen // alive; either way the lease follows the root, so the reservation goes with it. it.each(['unverifiable', 'live'] as const)( - 'releases a session whose CLI self-exited during create with its tree %s, refused in a sentence', + 'releases a chat whose CLI self-exited starting for its first message with its tree %s, said on the message', async (tree) => { claude.setSelfExit({ message: 'claude stream-json exited (code 1): claude: not signed in', exitVerdict: { root: 'exited', tree } }) + await ok<{ fence: number }>('agentSession.create', createIntentParams()) - const failed = await call('agentSession.create', createIntentParams()) - - // Answered once, as the refusal a replay of this operation gives, never thrown first. - expect(failed).toMatchObject({ - ok: true, - result: { - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - // The CLI's stderr is log text; the person reads what the chat's start failure says. - message: 'Claude stopped before it finished starting. Send your message to try again.', - ownerVerdict: 'exited' - } - } - }) + const sent = await send('hello') + + // The CLI's stderr is log text; the person reads what the start failure says, on the message. + await vi.waitFor(async () => + expect(await submissionOf(sent.clientMessageId)).toMatchObject({ + dispatchState: 'rejected', + reason: 'Claude stopped before it finished starting. Send your message to try again.', + rejection: { kind: 'providerStartFailed' } + }) + ) const lease = leaseOf(SESSION) - // Latching here would refuse every later attach with agent_session_ownership_unknown, + // Latching here would refuse every later start with agent_session_ownership_unknown, // wedging a user who only needs to sign in. expect(lease).toMatchObject({ claimStatus: 'released', handoffStage: null }) expect(lease.deathEvidence).toMatchObject({ @@ -475,31 +474,30 @@ describe('a structured Claude session over agentSession.*', () => { }) claude.setSelfExit(null) - // Signing in and reopening the chat works: the reservation was not latched. - await ok<{ fence: number }>('agentSession.ensure', ensureParams(lease.runtimeFence)) + // Signing in and sending again works: the reservation was not latched. + await send('again') + await vi.waitFor(() => expect(claude.live().sent.length).toBeGreaterThan(0)) } ) - it('answers a create whose whole CLI tree exited as exited on the first call', async () => { + it('rejects the first message when the whole CLI tree exited starting for it', async () => { claude.setSelfExit({ message: 'claude stream-json exited (code 1): claude: not signed in', // The common case: the close ladder proves the root and every descendant gone. exitVerdict: { root: 'exited', tree: 'exited' } }) + // The create answers at once; nothing has started yet. + await ok<{ fence: number }>('agentSession.create', createIntentParams()) - const failed = await call('agentSession.create', createIntentParams()) + const sent = await send('hello') - expect(failed).toMatchObject({ - ok: true, - result: { - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - message: 'Claude stopped before it finished starting. Send your message to try again.', - ownerVerdict: 'exited' - } - } - }) + await vi.waitFor(async () => + expect(await submissionOf(sent.clientMessageId)).toMatchObject({ + dispatchState: 'rejected', + reason: 'Claude stopped before it finished starting. Send your message to try again.', + rejection: { kind: 'providerStartFailed' } + }) + ) expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) claude.setSelfExit(null) }) @@ -511,7 +509,16 @@ describe('a structured Claude session over agentSession.*', () => { exitVerdict: { root: 'live', tree: 'unverifiable' } }) - await call('agentSession.create', createIntentParams()) + await ok<{ fence: number }>('agentSession.create', createIntentParams()) + const sent = await send('hello') + // Unproven, so the message waits for another start rather than ending. + await vi.waitFor(async () => + expect(await submissionOf(sent.clientMessageId)).toMatchObject({ + dispatchState: 'pending', + startFailure: { attempts: 1, rejection: { kind: 'startFailed' } } + }) + ) + expect(claude.connections).toHaveLength(1) claude.setSelfExit(null) // The adapter closed the stdio of what it spawned, and no owner was recorded to stop. The next @@ -526,7 +533,7 @@ describe('a structured Claude session over agentSession.*', () => { it.each(['unverifiable', 'live'] as const)( 'restarts a chat whose stop saw the Claude root exit with its tree %s', async (tree) => { - await ok('agentSession.create', createIntentParams()) + await startedChat() const first = claude.live() first.exitVerdict = { root: 'exited', tree } first.close = async () => { @@ -552,7 +559,7 @@ describe('a structured Claude session over agentSession.*', () => { ) it('routes a published Claude first-hand exit through fenced host reconciliation', async () => { - await ok<{ fence: number }>('agentSession.create', createIntentParams()) + await startedChat() const connection = claude.live() connection.exitVerdict = { root: 'exited', tree: 'unverifiable' } connection.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed')) @@ -568,7 +575,7 @@ describe('a structured Claude session over agentSession.*', () => { it.each(['unverifiable', 'live'] as const)( 'restarts a chat on its next send after a Claude crash whose tree was %s', async (tree) => { - const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) + await startedChat() const connection = claude.live() connection.exitVerdict = { root: 'exited', tree } connection.close = async () => { @@ -576,15 +583,12 @@ describe('a structured Claude session over agentSession.*', () => { return false } // Claude takes the message but crashes before echoing it. + const before = connection.sent.length connection.send = async (message) => { connection.sent.push(message) } - const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'in flight' }] } - const inFlight = ok<{ submission: { dispatchState: string; reason: string | null } }>( - 'agentSession.send', - { envelope: envelope('agentSession.send', { body }, created.fence), body } - ) - await vi.waitFor(() => expect(connection.sent).toHaveLength(1)) + const inFlight = send('in flight') + await vi.waitFor(() => expect(connection.sent).toHaveLength(before + 1)) connection.handlers.onExit?.(new Error('claude stream-json exited (code 1): crashed')) expect((await inFlight).submission).toMatchObject({ @@ -595,11 +599,7 @@ describe('a structured Claude session over agentSession.*', () => { await waitForStructuredAgentSessionRecovery() expect(claude.connections).toHaveLength(1) expect(leaseOf(SESSION)).toMatchObject({ claimStatus: 'released', handoffStage: null }) - const next = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'after' }] } - await ok('agentSession.send', { - envelope: envelope('agentSession.send', { body: next }, leaseOf(SESSION).runtimeFence), - body: next - }) + await send('after') await vi.waitFor(() => expect(claude.connections).toHaveLength(2)) expect(claude.live().launch.options).toMatchObject({ resume: PROVIDER_SESSION }) await vi.waitFor(() => expect(claude.live().sent).toHaveLength(1)) @@ -610,6 +610,21 @@ describe('a structured Claude session over agentSession.*', () => { it('creates, sends, streams, approves, interrupts, and resumes from the chain head', async () => { shellEnv = { ...shellEnv, ANTHROPIC_API_KEY: 'sk-ant-SHELL-LEAK' } const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) + // Created at rest: the first message is what starts Claude. + expect(claude.connections).toHaveLength(0) + const stream = await subscribe() + + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'List files' }] } + const sent = await ok<{ + submission: { dispatchState: string; providerItemId: string | null } + }>('agentSession.send', { + envelope: envelope('agentSession.send', { body }, created.fence), + body + }) + expect(sent.submission).toMatchObject({ + dispatchState: 'accepted', + providerItemId: `claude:${PROVIDER_SESSION}:user-1` + }) expect(claude.live().launch.options).toMatchObject({ sessionId: PROVIDER_SESSION }) expect(claude.live().launch.options.resume).toBeUndefined() expect(claude.live().launch.env).toMatchObject({ @@ -631,19 +646,6 @@ describe('a structured Claude session over agentSession.*', () => { ok: true, result: { providerSession: { key: 'session_id', id: PROVIDER_SESSION } } }) - const stream = await subscribe() - - const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'List files' }] } - const sent = await ok<{ - submission: { dispatchState: string; providerItemId: string | null } - }>('agentSession.send', { - envelope: envelope('agentSession.send', { body }, created.fence), - body - }) - expect(sent.submission).toMatchObject({ - dispatchState: 'accepted', - providerItemId: `claude:${PROVIDER_SESSION}:user-1` - }) claude.live().handlers.onMessage?.({ type: 'stream_event', @@ -802,8 +804,8 @@ describe('a structured Claude session over agentSession.*', () => { provider: 'claude', leafUuid: 'assistant-leaf' }) - // A Claude Stop ends its child once Claude ends the stopped turn, so the chat rests; the next - // open resumes the conversation. + // A Claude Stop ends its child once Claude ends the stopped turn, so the chat rests; its next + // message resumes the conversation. const old = claude.live() old.handlers.onMessage?.({ type: 'result', @@ -815,12 +817,10 @@ describe('a structured Claude session over agentSession.*', () => { await vi.waitFor(() => expect(leaseOf(SESSION).claimStatus).toBe('released')) expect(old.closed).toBe(true) const rested = leaseOf(SESSION) - const resumed = await ok<{ fence: number }>( - 'agentSession.ensure', - ensureParams(rested.runtimeFence) - ) - expect(resumed.fence).toBe(rested.runtimeFence + 1) - expect(claude.live()).not.toBe(old) + await send('next') + await vi.waitFor(() => expect(claude.live()).not.toBe(old)) + const resumed = leaseOf(SESSION).runtimeFence + expect(resumed).toBe(rested.runtimeFence + 1) // Claude owns where the conversation continues; the stored leaf is the stopped turn's, which // Claude ended with its own result. expect(claude.live().launch.options).toMatchObject({ resume: PROVIDER_SESSION }) @@ -836,9 +836,12 @@ describe('a structured Claude session over agentSession.*', () => { expect(host.deps.store.getRecord(SESSION).providerHandleChain.at(-1)).toMatchObject( lastCompletedTurn ) - // Open, close, open with no turn in between keeps that leaf. - const reopened = await ok<{ fence: number }>('agentSession.ensure', ensureParams(resumed.fence)) - expect(reopened.fence).toBe(resumed.fence + 1) + // Start, stop, start with no completed turn in between keeps that leaf. + const second = claude.live() + await getStructuredAgentSessionHost()?.close(SESSION, 'evict') + await send('again') + await vi.waitFor(() => expect(claude.live()).not.toBe(second)) + expect(leaseOf(SESSION).runtimeFence).toBeGreaterThan(resumed) expect(host.deps.store.getRecord(SESSION).providerHandleChain.at(-1)).toMatchObject( lastCompletedTurn ) diff --git a/src/main/runtime/mobile-rpc-allowlist.test.ts b/src/main/runtime/mobile-rpc-allowlist.test.ts index 841c22ae9255..e08361d14557 100644 --- a/src/main/runtime/mobile-rpc-allowlist.test.ts +++ b/src/main/runtime/mobile-rpc-allowlist.test.ts @@ -157,7 +157,6 @@ describe('mobile RPC allowlist', () => { ).toEqual([ 'agentSession.createSupport', 'agentSession.create', - 'agentSession.ensure', 'agentSession.reveal', 'agentSession.send', 'agentSession.cancel', diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts index c3c69dc15fd2..782d17cd2206 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts @@ -234,7 +234,7 @@ describe('structured worker session', () => { describe('structured worker dispatch preamble', () => { type PreambleHost = Parameters[0]['host'] - type Settled = Pick + type Settled = Pick function submissionOf(settled: Settled): AgentJournalSubmission { return { @@ -276,7 +276,7 @@ describe('structured worker dispatch preamble', () => { it('reports the preamble delivered only on an accepted submission', async () => { await expect( send(hostWithSubmission({ dispatchState: 'accepted', reason: null })) - ).resolves.toBe('accepted') + ).resolves.toEqual({ state: 'accepted' }) }) it('waits for an accepted preamble to be delivered, and reports that delivery (W10)', async () => { @@ -287,7 +287,21 @@ describe('structured worker dispatch preamble', () => { { dispatchState: 'accepted', reason: null } ) ) - ).resolves.toBe('accepted') + ).resolves.toEqual({ state: 'accepted' }) + }) + + // The preamble is what starts the worker's agent, so a start still being retried is why it waits. + it('names the failed start a held preamble waits behind', async () => { + const startFailure = { + attempts: 1, + reason: "Codex couldn't start. Orca will try again shortly.", + rejection: { kind: 'startFailed' as const }, + failedAt: 1, + nextAttemptAt: 2 + } + await expect( + send(hostWithSubmission({ dispatchState: 'pending', reason: null, startFailure })) + ).resolves.toEqual({ state: 'pending', startFailure }) }) it('reports a preamble still held for an agent that outlasted the wait, without failing the start (W10)', async () => { @@ -295,7 +309,7 @@ describe('structured worker dispatch preamble', () => { // down, which rejected the preamble the start was about to deliver. await expect( send(hostWithSubmission({ dispatchState: 'pending', reason: null })) - ).resolves.toBe('pending') + ).resolves.toEqual({ state: 'pending' }) }) it('never claims delivery for a submission the provider never acknowledged', async () => { diff --git a/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts index 9531e0f468b2..584a27fd309e 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-mode-opacity.test.ts @@ -47,7 +47,7 @@ vi.mock('./orchestration-structured-worker-session', async (importOriginal) => ( ...(await importOriginal>()), sendStructuredWorkerPreamble: async (args: { preamble: string }) => { structuredPreambles.push(args.preamble) - return 'accepted' + return { state: 'accepted' as const } }, releaseStructuredWorkerSession: () => {}, discardStructuredWorkerSession: async () => {} diff --git a/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts b/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts index a2beea9ea05c..2c26a246a386 100644 --- a/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-worker-start-mode-selection.test.ts @@ -30,7 +30,7 @@ vi.mock('./orchestration/federation/federated-worker-start', () => ({ })) vi.mock('./orchestration-structured-worker-session', async (importOriginal) => ({ ...(await importOriginal>()), - sendStructuredWorkerPreamble: async () => 'accepted', + sendStructuredWorkerPreamble: async () => ({ state: 'accepted' as const }), releaseStructuredWorkerSession: () => {}, discardStructuredWorkerSession: async () => {} })) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts index 64c7ec1c8b18..fbe1c93a7eed 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts @@ -227,7 +227,8 @@ describe('committed adopting create RPC replay', () => { } } }) - expect(sessionAdapter.acquire).toHaveBeenCalledTimes(1) + // The adopting create starts no agent; its first send would. + expect(sessionAdapter.acquire).not.toHaveBeenCalled() expect(internal.publishStructuredAgentSessionTab).toHaveBeenCalledTimes(2) expect(selectAccountHome).toHaveBeenCalledTimes(1) @@ -240,7 +241,7 @@ describe('committed adopting create RPC replay', () => { ok: true, result: { ok: false, refusal: { code: 'agent_session_identity_required' } } }) - expect(sessionAdapter.acquire).toHaveBeenCalledTimes(1) + expect(sessionAdapter.acquire).not.toHaveBeenCalled() expect(internal.publishStructuredAgentSessionTab).toHaveBeenCalledTimes(2) }) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts index a2876f284db8..2feb25025c7d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts @@ -54,7 +54,6 @@ export const ADMISSION_METHODS = [ agent: 'codex' } }, - { method: 'agentSession.ensure', params: attachParams() }, { method: 'agentSession.send', params: sendParams() }, { method: 'agentSession.queuedMessageSend', diff --git a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts index f34ecb5d6cd8..246e86dee422 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts @@ -1,5 +1,5 @@ -// The create route's pre-commit boundary: a failure before `attach` must reach the client as a -// refusal it can classify, and a failure at or after `attach` must not. +// The create route's pre-commit boundary: a failure before the host's `create` must reach the +// client as a refusal it can classify, and a failure at or after it must not. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' @@ -39,17 +39,17 @@ function createParams(overrides: Record = {}) { } } -let attach: ReturnType +let hostCreate: ReturnType function hostStub(): StructuredAgentSessionHost { - attach = vi.fn(async () => ({ + hostCreate = vi.fn(async () => ({ ok: true, replayed: false, fence: 1, cursor: { epoch: 'epoch-a', sequence: 0 }, value: { sessionId: SESSION, fence: 1, page: {}, unconfirmedClientMessageIds: [] } })) - return { attach } as unknown as StructuredAgentSessionHost + return { create: hostCreate } as unknown as StructuredAgentSessionHost } const resolvedIntent = { @@ -133,7 +133,7 @@ describe('a create refused before it commits', () => { expect(refusal?.message).toContain('structured agent chat') expect(refusal?.message).not.toContain('Codex') expect(isDefinitiveAgentSessionCreateRefusal(refusal?.code)).toBe(true) - expect(attach).not.toHaveBeenCalled() + expect(hostCreate).not.toHaveBeenCalled() }) it('answers a code-less failure as a definitive envelope too, keeping the cause in the message', async () => { @@ -149,7 +149,7 @@ describe('a create refused before it commits', () => { expect(refusal?.message).toContain('structured agent chat') expect(refusal?.message).not.toContain('Codex') expect(refusal?.message).toContain('No worktree matches id:workspace-1') - expect(attach).not.toHaveBeenCalled() + expect(hostCreate).not.toHaveBeenCalled() }) it('answers a host that will not install as a definitive envelope', async () => { @@ -183,13 +183,13 @@ describe('a create refused before it commits', () => { ) expect(refusalOf(response)?.code).toBe('agent_session_operation_conflict') - expect(attach).not.toHaveBeenCalled() + expect(hostCreate).not.toHaveBeenCalled() }) }) describe('the boundary the envelope stops at', () => { - it('leaves a failure at attach unknown, because it may have committed', async () => { - attach.mockRejectedValueOnce(new Error('attach exploded')) + it('leaves a failure in the host create unknown, because it may have committed', async () => { + hostCreate.mockRejectedValueOnce(new Error('create exploded')) const response = await create() diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index 85d3e2a5b4bf..fa0903074c5f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -124,7 +124,7 @@ function statusFeed(): StructuredAgentSessionStatusFeed { export function hostStub(): StructuredAgentSessionHost { reset(hostCalls) Object.assign(hostCalls, { - attach: vi.fn(async () => ({ + create: vi.fn(async () => ({ ok: true, replayed: false, fence: 1, diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index cc0677b134a5..5208fda440b3 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -174,7 +174,7 @@ describe('capability gating', () => { } // Bump deliberately: the whole agentSession.* surface is behind the structured capability, // so an additive method is invisible to old clients and needs no protocol bump. - expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(32) + expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(31) }) it('hides the surface from a declared client that did not advertise it', async () => { @@ -398,14 +398,14 @@ describe('method routing', () => { ...params, callerKey: 'trusted-local:runtime' }) - expect(hostCalls.attach).toHaveBeenCalledWith( + expect(hostCalls.create).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ accountHome: { variable: 'CODEX_HOME', path: '/host/.codex' }, options: { model: 'gpt-5.6-sol', effort: 'medium' } }) ) - expect(hostCalls.attach.mock.calls[0]?.[1]).not.toHaveProperty('providerHandle') + expect(hostCalls.create.mock.calls[0]?.[1]).not.toHaveProperty('providerHandle') expect(runtimeCalls.publishStructuredAgentSessionTab).toHaveBeenCalledWith( expect.objectContaining({ sessionId: SESSION, activate: true }) ) @@ -431,7 +431,7 @@ describe('method routing', () => { }) // Beside `options`, after the attach fingerprint: which tab shows the chat is not which // conversation this attaches to. - expect(hostCalls.attach).toHaveBeenCalledWith( + expect(hostCalls.create).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ surfaceTabId: 'chat-tab-1' }) ) @@ -458,7 +458,7 @@ describe('method routing', () => { ok: true, result: { ok: false, refusal: { code: 'agent_session_operation_conflict' } } }) - expect(hostCalls.attach).not.toHaveBeenCalled() + expect(hostCalls.create).not.toHaveBeenCalled() }) it.each(['agent-session:with-colon', 'web-terminal-local-surface'])( @@ -480,7 +480,7 @@ describe('method routing', () => { ok: false, error: { code: 'invalid_argument', message: expect.stringContaining('Invalid chat tab ID') } }) - expect(hostCalls.attach).not.toHaveBeenCalled() + expect(hostCalls.create).not.toHaveBeenCalled() } ) @@ -545,7 +545,7 @@ describe('method routing', () => { ...params, callerKey: 'trusted-local:runtime' }) - expect(hostCalls.attach).toHaveBeenCalledWith( + expect(hostCalls.create).toHaveBeenCalledWith( expect.anything(), expect.objectContaining({ accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/host/.claude' } @@ -560,7 +560,7 @@ describe('method routing', () => { ) }) - it('reports an unknown create outcome when attach commits before tab publication fails', async () => { + it('reports an unknown create outcome when the create commits before tab publication fails', async () => { const worktree = 'id:workspace-1' const params = { envelope: envelope({ @@ -581,7 +581,7 @@ describe('method routing', () => { }) }) - expect(hostCalls.attach).toHaveBeenCalledOnce() + expect(hostCalls.create).toHaveBeenCalledOnce() expect(response).toMatchObject({ ok: true, result: { @@ -591,19 +591,16 @@ describe('method routing', () => { }) }) - it('separates create from ensure by the fence the client may declare', async () => { + it('refuses a create that names a runtime fence', async () => { const created = await call('agentSession.create', attachParams()) expect(created).toMatchObject({ ok: true }) const fenced = await call('agentSession.create', attachParams({ envelope: envelope() })) expect(fenced).toMatchObject({ ok: false }) - - const ensured = await call('agentSession.ensure', attachParams({ envelope: envelope() })) - expect(ensured).toMatchObject({ ok: true }) }) - /** A client-supplied location skips the worktree-resolving support check, so both attach-shaped - * entries must ask the executing host directly or a host that cannot fence a provider child + /** A client-supplied location skips the worktree-resolving support check, so an attach-shaped + * create must ask the executing host directly or a host that cannot fence a provider child * would create one anyway. */ it('returns a refusal envelope when create cannot support a client-supplied location', async () => { hostCalls.supportsCreate.mockReturnValue(false) @@ -617,20 +614,7 @@ describe('method routing', () => { refusal: { code: 'structured_agent_session_unsupported' } } }) - expect(hostCalls.attach).not.toHaveBeenCalled() - expect(hostCalls.supportsCreate).toHaveBeenCalledWith(attachParams().location, 'codex') - }) - - it('keeps ensure failures as top-level errors for an unsupported client location', async () => { - hostCalls.supportsCreate.mockReturnValue(false) - - const refused = await call('agentSession.ensure', attachParams()) - - expect(refused).toMatchObject({ - ok: false, - error: { message: expect.stringContaining('structured_agent_session_unsupported') } - }) - expect(hostCalls.attach).not.toHaveBeenCalled() + expect(hostCalls.create).not.toHaveBeenCalled() expect(hostCalls.supportsCreate).toHaveBeenCalledWith(attachParams().location, 'codex') }) diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts index 0f79563fc0d0..187e3192636e 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts @@ -510,17 +510,15 @@ describe('a create that reserves its tab', () => { }) }) - it('leaves no tab behind when the create fails, so nothing is restored and the id is free', async () => { + // A create starts no agent, so a start that would fail cannot cost the chat its tab. + it('gives the chat its tab even when its agent could not start', async () => { acquireFails = true - expect(await createChat(HOST_TEST_SESSION, 'reserved-tab')).toMatchObject({ ok: false }) - expect(store.getSessionTabId(HOST_TEST_SESSION)).toBeNull() - expect(store.listVisibleSessionIds()).toEqual([]) - - acquireFails = false - expect(await createChat('session-bravo', 'reserved-tab')).toMatchObject({ + expect(await createChat(HOST_TEST_SESSION, 'reserved-tab')).toMatchObject({ ok: true, value: { tabId: 'reserved-tab' } }) + expect(store.getSessionTabId(HOST_TEST_SESSION)).toBe('reserved-tab') + expect(await createChat('session-bravo', 'reserved-tab')).toMatchObject({ ok: false }) }) }) diff --git a/src/main/runtime/structured-agent-session-integration-replay.test.ts b/src/main/runtime/structured-agent-session-integration-replay.test.ts index b4d0399d10da..fd3718d50f2d 100644 --- a/src/main/runtime/structured-agent-session-integration-replay.test.ts +++ b/src/main/runtime/structured-agent-session-integration-replay.test.ts @@ -75,20 +75,32 @@ describe('a structured codex session over agentSession.*', () => { const host = await ensureStructuredAgentSessionHost(harness.hostConfig()) const adapter = (host as unknown as { deps: { adapter: CodexStructuredSessionAdapter } }).deps .adapter - const historyEntered = Promise.withResolvers() - const historyGate = Promise.withResolvers() - const originalHistoryFilePath = adapter.historyFilePath.bind(adapter) - vi.spyOn(adapter, 'historyFilePath').mockImplementation(async (input) => { - historyEntered.resolve() - await historyGate.promise - return originalHistoryFilePath(input) + // Held once the child exists and before the attach binds it: rows it emits now are buffered. + const acquired = Promise.withResolvers() + const bindGate = Promise.withResolvers() + const originalAcquire = adapter.acquire.bind(adapter) + vi.spyOn(adapter, 'acquire').mockImplementation(async (input) => { + const result = await originalAcquire(input) + acquired.resolve() + await bindGate.promise + return result }) - const creating = harness.ok<{ fence: number }>( + const created = await harness.ok<{ fence: number }>( 'agentSession.create', harness.createIntentParams() ) - await historyEntered.promise + // The first send's start is the acquiring attach. + const body = { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: 'go' }] + } + const sending = harness.ok('agentSession.send', { + envelope: harness.envelope('agentSession.send', { body }, created.fence), + body + }) + await acquired.promise harness.codex.notify('turn/started', { threadId: THREAD, turn: { id: TURN } }) harness.codex.notify('item/started', { threadId: THREAD, @@ -108,8 +120,8 @@ describe('a structured codex session over agentSession.*', () => { }) await new Promise((resolve) => setImmediate(resolve)) const waitedForJournalBind = !stopped - historyGate.resolve() - await creating + bindGate.resolve() + await sending await stopping expect(waitedForJournalBind).toBe(true) diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index cf41fdb1dfe0..d6e3e8149d2c 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -251,6 +251,19 @@ async function ok(method: string, params: unknown): Promise { return result.value as T } +/** A chat is created at rest; its first send is what starts the agent, for a test that needs it. */ +async function sendToStart(fence: number, text = 'start'): Promise { + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } + const sent = await ok<{ clientMessageId: string }>('agentSession.send', { + envelope: envelope('agentSession.send', { body }, fence), + body + }) + await vi.waitFor(() => + expect(codex.live().calls).toContainEqual(expect.objectContaining({ method: 'turn/start' })) + ) + return sent.clientMessageId +} + /** Opens a live subscription and keeps collecting frames after the call settles. */ async function subscribe( requestId: string, @@ -431,18 +444,26 @@ describe('a structured codex session over agentSession.*', () => { // The previous process exits, closing its database. await journals.closeAll() - const created = await ok<{ page: { items: AgentJournalRenderItem[] } }>( + const created = await ok<{ fence: number; page: { items: AgentJournalRenderItem[] } }>( 'agentSession.create', createIntentParams() ) expect(created.page.items.map(textOf)).toContain('legacy question') - expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ - ok: true, - result: { - models: [{ id: 'gpt-live', defaultEffort: 'medium' }], - current: { model: 'gpt-live' } - } + // The live options come from the agent, which the first send starts. + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'next' }] } + await ok('agentSession.send', { + envelope: envelope('agentSession.send', { body }, created.fence), + body }) + await vi.waitFor(async () => + expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ + ok: true, + result: { + models: [{ id: 'gpt-live', defaultEffort: 'medium' }], + current: { model: 'gpt-live' } + } + }) + ) }) it('dispatches and streams a plain first send from a fresh session', async () => { @@ -451,11 +472,8 @@ describe('a structured codex session over agentSession.*', () => { createIntentParams() ) expect(created.page.items).toEqual([]) - expect(codex.live().launch.env).toMatchObject({ - CODEX_PROFILE: 'configured', - EXAMPLE_GATEWAY_TOKEN: 'shell-exported', - CODEX_HOME: '/home/dev/.codex' - }) + // The chat is created at rest: its first send starts the agent. + expect(() => codex.live()).toThrow('no codex app-server has been opened') const store = await readPersistedTestAgentSessionStoreText(root) // The record this create wrote, so the checks below read the runtime's own rows. expect(store).toContain('/home/dev/.codex') @@ -476,6 +494,11 @@ describe('a structured codex session over agentSession.*', () => { // which message landed where is knowable only from the echo. expect(sent.submission).toMatchObject({ dispatchState: 'pending', providerItemId: null }) await handedOverAs({ threadId: THREAD, clientUserMessageId: sent.clientMessageId }) + expect(codex.live().launch.env).toMatchObject({ + CODEX_PROFILE: 'configured', + EXAMPLE_GATEWAY_TOKEN: 'shell-exported', + CODEX_HOME: '/home/dev/.codex' + }) codex.notify('turn/started', { turn: { id: TURN } }) // Codex echoes the message back carrying the `clientId` it was sent under, @@ -516,23 +539,17 @@ describe('a structured codex session over agentSession.*', () => { createIntentParams() ) expect(created.page.items).toEqual([]) - expect(codex.live().calls[0]).toMatchObject({ - method: 'thread/start', - params: { cwd: `/repos/${WORKSPACE}` } - }) const fence = created.fence const stream = await subscribe('sub-1') expect(stream[0]).toMatchObject({ type: 'snapshot', sessionId: SESSION }) // ── options ───────────────────────────────────────────────────────────── - const options = await call('agentSession.options', { sessionId: SESSION }) - expect(options).toMatchObject({ + // At rest the list is the host's model catalog, which this harness has none of; a pick made now + // is the record's intent, which the first send's start applies. + expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ ok: true, - result: { - models: [{ id: 'gpt-live', defaultEffort: 'medium' }], - current: { model: 'gpt-live' } - } + result: { models: [] } }) await ok('agentSession.setOption', { envelope: envelope('agentSession.setOption', { key: 'model', value: 'gpt-live' }, fence), @@ -569,6 +586,11 @@ describe('a structured codex session over agentSession.*', () => { model: 'gpt-live', effort: 'high' }) + // The send started the agent; the create had not. + expect(codex.live().calls[0]).toMatchObject({ + method: 'thread/start', + params: { cwd: `/repos/${WORKSPACE}` } + }) // ── stream ────────────────────────────────────────────────────────────── codex.notify('turn/started', { turn: { id: TURN } }) @@ -678,16 +700,15 @@ describe('a structured codex session over agentSession.*', () => { ).toBe(true) expect(itemsOf(missed).map(textOf).filter(Boolean)).toEqual(['Stopped.']) - // A runtime taking the session over is the other half of reconnect: the - // fence advances, the old child is reaped, and its replacement resumes the - // thread this session proved rather than forking a new one. + // The other half of reconnect: a closed chat's next send starts a replacement that resumes the + // thread this session proved rather than forking a new one, at an advanced fence. const reaped = codex.live() - const resumed = await ok<{ fence: number; page: { items: AgentJournalRenderItem[] } }>( - 'agentSession.ensure', - attachParams(fence) - ) - expect(resumed.fence).toBe(fence + 1) + await ok('agentSession.close', { sessionId: SESSION }) expect(reaped.closed).toBe(true) + await sendToStart(fence, 'again') + expect( + getStructuredAgentSessionHost()?.deps.store.getRecord(SESSION)?.lease.runtimeFence + ).toBeGreaterThan(fence) expect(codex.live().resumedThreadId).toBe(THREAD) expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ ok: true, @@ -696,8 +717,6 @@ describe('a structured codex session over agentSession.*', () => { current: { model: 'gpt-live', effort: 'high' } } }) - // The journal belongs to the session, not to the process that just died. - expect(resumed.page.items.map(textOf)).toContain('Two files.') // ── page history ──────────────────────────────────────────────────────── const tail = await historyPage('tail', { limit: 2 }) @@ -724,6 +743,7 @@ describe('a structured codex session over agentSession.*', () => { 'tool-call', 'approval', 'status', + 'message', 'message' ]) expect([...older.page.items, ...tail.page.items].map(textOf)).toEqual([ @@ -733,24 +753,30 @@ describe('a structured codex session over agentSession.*', () => { '', '', '', - 'Stopped.' + 'Stopped.', + 'again' ]) }) it('caches shell exports but re-reads configured overrides for a resume', async () => { shellEnvironmentPolicy = { inheritAll: false, names: [] } const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) + // Nothing is read for a create: the first send's start reads the overrides. + expect(codexOverrideReads).toBe(0) + await sendToStart(created.fence) expect(codex.live().launch.env?.EXAMPLE_GATEWAY_TOKEN).toBeUndefined() expect({ bootEnvironmentReads, codexOverrideReads }).toEqual({ bootEnvironmentReads: 1, codexOverrideReads: 1 }) + const started = codex.live() configuredCodexProfile = 'updated' shellEnvironmentPolicy = { inheritAll: false, names: ['EXAMPLE_GATEWAY_TOKEN'] } - const resumed = await ok<{ fence: number }>('agentSession.ensure', attachParams(created.fence)) + await ok('agentSession.close', { sessionId: SESSION }) + await sendToStart(created.fence, 'again') - expect(resumed.fence).toBe(created.fence + 1) + expect(codex.live()).not.toBe(started) expect(codex.live().resumedThreadId).toBe(THREAD) expect(codex.live().launch.env).toMatchObject({ CODEX_PROFILE: 'updated', @@ -783,7 +809,8 @@ describe('a structured codex session over agentSession.*', () => { }) it('joins final deferred writes before runtime teardown completes', async () => { - await ok<{ fence: number }>('agentSession.create', createIntentParams()) + const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) + await sendToStart(created.fence) codex.notify('turn/started', { threadId: THREAD, turn: { id: TURN } }) codex.notify('item/started', { threadId: THREAD, @@ -844,7 +871,8 @@ describe('a structured codex session over agentSession.*', () => { }) it('persists truncated command output before publishing its journal row', async () => { - await ok<{ fence: number }>('agentSession.create', createIntentParams()) + const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) + await sendToStart(created.fence) const output = 'large command output\n'.repeat(2_000) codex.notify('item/completed', { @@ -872,6 +900,7 @@ describe('a structured codex session over agentSession.*', () => { it('keeps an answered prompt resolved after the provider exits', async () => { const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) + await sendToStart(created.fence) codex.notify('turn/started', { threadId: THREAD, turn: { id: TURN } }) codex.notify('item/started', { threadId: THREAD, diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index 4aaa02037ec3..2602a4a0845c 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -503,7 +503,7 @@ describe('cross-version structured agent sessions', () => { now: () => NOW }) setStructuredAgentSessionHost(host) - const attached = await host.attach({ callerKey: 'test' }, attachParams(null) as never) + const attached = await host.create({ callerKey: 'test' }, attachParams(null) as never) expect(attached.ok).toBe(true) createMobileSessionTerminal = vi.fn() runtime = { @@ -722,21 +722,6 @@ describe('cross-version structured agent sessions', () => { refusal?: { code: string; currentFence?: number } } - /** Reattaching after a restart: the client's fence died with the previous - * host, and the refusal that says so is what hands it the live one. */ - async function reattach(staleFence: number): Promise { - const refused = await answer('agentSession.ensure', attachParams(staleFence)) - expect(refused).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_checkpoint_stale' } - }) - const currentFence = refused.refusal?.currentFence - expect(currentFence).toBeGreaterThan(staleFence) - const reattached = await answer('agentSession.ensure', attachParams(currentFence ?? 0)) - expect(reattached).toMatchObject({ ok: true }) - return reattached - } - async function call( method: string, params: unknown, @@ -809,16 +794,19 @@ describe('cross-version structured agent sessions', () => { }) // Every released client still sends the fence it last saw; this host names a write by its - // target and ignores that fence. Only the attach keeps comparing one, which `reattach` pins. + // target and ignores that fence. The first send's start moved it past the one create answered. it('delivers a write still fenced to the host generation that died', async () => { const created = await answer('agentSession.create', createIntentParams()) + expect(await answer('agentSession.send', sendParams('first', created.fence))).toMatchObject({ + ok: true + }) await bootHost('b') - const reattached = await reattach(created.fence) - expect(reattached.fence).toBeGreaterThan(created.fence) + const current = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + expect(current).toBeGreaterThan(created.fence) expect(await answer('agentSession.send', sendParams('stale', created.fence))).toMatchObject({ ok: true, - fence: reattached.fence + fence: store.getRecord(SESSION)?.lease.runtimeFence }) }) diff --git a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts index 681c65f50c12..b89a1edb1427 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts @@ -23,7 +23,7 @@ export function structuredHostStub( restartResumableDismiss: vi.fn(async () => 0), restartResumeAll: vi.fn(async () => []), restartContinueAll: vi.fn(async () => ({ resumed: [], continued: [] })), - attach: vi.fn(async () => ({ ok: true, replayed: false, value: { sessionId } })), + create: vi.fn(async () => ({ ok: true, replayed: false, value: { sessionId } })), // Attach-shaped entries take a client-supplied location, so the host is asked whether it // supports creating there. A real host always answers; leaving it unstubbed made every // `ensure` refuse for the harness's own reason rather than the location's. diff --git a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts index 7eb4920deec3..ba6d0d1b8028 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-surface-manifest.ts @@ -49,12 +49,7 @@ export const STRUCTURED_CALLS: { { method: 'agentSession.createSupport', hostMethod: null, result: { supported: true } }, { method: 'agentSession.create', - hostMethod: 'attach', - result: { ok: true, replayed: false, value: { sessionId: SESSION } } - }, - { - method: 'agentSession.ensure', - hostMethod: 'attach', + hostMethod: 'create', result: { ok: true, replayed: false, value: { sessionId: SESSION } } }, { @@ -250,8 +245,6 @@ export function paramsFor(method: string): unknown { return { worktree: `id:${WORKSPACE}`, agent: 'codex' } case 'agentSession.create': return createIntentParams() - case 'agentSession.ensure': - return attachParams(fence) case 'agentSession.conversationCommand': { const fields = { command: 'compact' } return { envelope: envelope({ method, fields, fence }), ...fields } diff --git a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts index e656b320b627..5384b0e93473 100644 --- a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts +++ b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts @@ -25,6 +25,7 @@ import { runWorktreeAgentActivationGate } from '../../src/renderer/src/lib/workt import { readWorktreeStructuredActivationInventory } from '../../src/renderer/src/lib/worktree-agent-structured-inventory' import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types' import { openTestJournalHostDatabase } from '../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import { attachForTests } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-support' const WORKTREE = 'repo-1::/workspace/repo' @@ -117,7 +118,9 @@ beforeEach(async () => { closeSession = vi.fn(async () => true) store = await openTestAgentSessionRecordStore(root) openHost() - expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({ + expect( + await attachForTests(host, { callerKey: 'client-1' }, hostTestAttachParams(null)) + ).toMatchObject({ ok: true }) serveDesktopRuntime() From 67468b7a423f04a3c8b7380d82062bf22762c9bc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 12:57:24 -0700 Subject: [PATCH 03/84] test(native-chat): split the provider-row integration tests, and settle a create whose import failed --- ...ctured-agent-session-forget-status.test.ts | 1 + ...ession-gate-classification.test-fixture.ts | 7 +- ...ed-agent-session-precommit-refusal.test.ts | 1 + .../rpc/methods/structured-agent-session.ts | 2 +- ...-session-integration-provider-rows.test.ts | 133 ++++++++++++++++++ ...ructured-agent-session-integration.test.ts | 84 ----------- .../structured-chat-coordinator-mail.test.ts | 8 +- ...ss-version-agent-session-wire.unit.test.ts | 1 + 8 files changed, 141 insertions(+), 96 deletions(-) create mode 100644 src/main/runtime/structured-agent-session-integration-provider-rows.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts index ecbf6916ecba..3999703d4d8a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -198,6 +198,7 @@ function attachContext( } as unknown as StructuredAgentSessionAttachContext } +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the attach fails before reading anything past the envelope. const attachParams = { envelope: { sessionId: SESSION, clientOperationId: 'op-1' } } as unknown as Parameters[2] diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts index 2feb25025c7d..2cd895e1916d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate-classification.test-fixture.ts @@ -1,12 +1,7 @@ // The method-to-gate classification from `structured-agent-session-gate.ts`, as a table the // suites iterate. Adding an `agentSession.*` method means adding it to exactly one of these. -import { - attachParams, - envelope, - sendParams, - SESSION -} from './structured-agent-session-rpc.test-fixture' +import { envelope, sendParams, SESSION } from './structured-agent-session-rpc.test-fixture' import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' /** Stops or retires work the caller already owns, so admission may already have been revoked. */ diff --git a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts index 246e86dee422..94029c3f5d0f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.test.ts @@ -49,6 +49,7 @@ function hostStub(): StructuredAgentSessionHost { cursor: { epoch: 'epoch-a', sequence: 0 }, value: { sessionId: SESSION, fence: 1, page: {}, unconfirmedClientMessageIds: [] } })) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the create route reaches only `create` on the host it is handed. return { create: hostCreate } as unknown as StructuredAgentSessionHost } diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 0af547a37ee4..4d0798bd7db3 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -50,7 +50,7 @@ import { STRUCTURED_AGENT_SESSION_THREAD_GOAL_METHODS } from './structured-agent import { STRUCTURED_AGENT_SESSION_CONVERSATION_OUTLINE_METHODS } from './structured-agent-session-conversation-outline' import { STRUCTURED_AGENT_SESSION_OPTIONS_READ_METHODS } from './structured-agent-session-options-read' import { - AttachParams, + type AttachParams, CancelParams, ConversationCommandParams, CreateParams, diff --git a/src/main/runtime/structured-agent-session-integration-provider-rows.test.ts b/src/main/runtime/structured-agent-session-integration-provider-rows.test.ts new file mode 100644 index 000000000000..ffd005cee9ad --- /dev/null +++ b/src/main/runtime/structured-agent-session-integration-provider-rows.test.ts @@ -0,0 +1,133 @@ +// What a running Codex agent writes into the journal, driven over `agentSession.*`; split from +// `structured-agent-session-integration.test.ts`, whose shared RPC harness this uses. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { + openStructuredCodexRpcHarness, + SESSION, + THREAD, + TURN, + type StructuredCodexRpcHarness +} from './structured-codex-session-rpc-test-harness' + +let harness: StructuredCodexRpcHarness + +beforeEach(async () => { + harness = await openStructuredCodexRpcHarness() +}) + +afterEach(async () => { + await harness.dispose() +}) + +function drainStreamedEvents(): Promise { + return getStructuredAgentSessionHost()?.flushStreamedEvents(SESSION) ?? Promise.resolve() +} + +/** A chat is created at rest; its first send is what starts the agent these rows come from. */ +async function sendToStart(fence: number): Promise { + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'start' }] } + await harness.ok('agentSession.send', { + envelope: harness.envelope('agentSession.send', { body }, fence), + body + }) + await vi.waitFor(() => + expect(harness.codex.live().calls).toContainEqual( + expect.objectContaining({ method: 'turn/start' }) + ) + ) +} + +describe('a running structured codex agent', () => { + it('persists truncated command output before publishing its journal row', async () => { + const created = await harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) + await sendToStart(created.fence) + const output = 'large command output\n'.repeat(2_000) + + harness.codex.notify('item/completed', { + threadId: THREAD, + turnId: TURN, + item: { + type: 'commandExecution', + id: 'item-large-output', + command: 'print-many-lines', + status: 'completed', + exitCode: 0, + aggregatedOutput: output + } + }) + await drainStreamedEvents() + + const host = getStructuredAgentSessionHost() + const journal = ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal + const item = journal.snapshot().items.find((candidate) => candidate.body?.kind === 'tool-call') + const bounded = item?.body?.kind === 'tool-call' ? item.body.output : undefined + expect(bounded).toMatchObject({ truncated: true, byteLength: Buffer.byteLength(output) }) + }) + + it('keeps an answered prompt resolved after the provider exits', async () => { + const created = await harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) + await sendToStart(created.fence) + harness.codex.notify('turn/started', { threadId: THREAD, turn: { id: TURN } }) + harness.codex.notify('item/started', { + threadId: THREAD, + turnId: TURN, + item: { + type: 'commandExecution', + id: 'item-needs-answer', + command: 'build', + status: 'inProgress' + } + }) + harness.codex.ask(9, 'item/commandExecution/requestApproval', { + threadId: THREAD, + turnId: TURN, + itemId: 'item-needs-answer', + availableDecisions: ['accept', 'decline'] + }) + await drainStreamedEvents() + const host = getStructuredAgentSessionHost() + const journal = ( + host as unknown as { sessions: Map } + ).sessions.get(SESSION)!.journal + const approval = journal.snapshot().items.find((item) => item.body?.kind === 'approval') + expect(approval?.body).toMatchObject({ + kind: 'approval', + resolution: { state: 'pending' } + }) + + await harness.ok('agentSession.respondToApproval', { + envelope: harness.envelope( + 'agentSession.respondTo:approval', + { + itemId: approval?.itemId, + expectedRevision: approval?.revision, + optionId: 'accept' + }, + created.fence + ), + itemId: approval?.itemId, + expectedRevision: approval?.revision, + optionId: 'accept' + }) + harness.codex.live().handlers.onExit?.(new Error('provider exited after answer')) + await drainStreamedEvents() + + expect( + journal.snapshot().items.find((item) => item.itemId === approval?.itemId)?.body + ).toMatchObject({ + kind: 'approval', + resolution: { state: 'resolved', selectedOptionId: 'accept' } + }) + }) +}) diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index d6e3e8149d2c..ad363e1dedba 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -869,88 +869,4 @@ describe('a structured codex session over agentSession.*', () => { ) ).toBe(false) }) - - it('persists truncated command output before publishing its journal row', async () => { - const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) - await sendToStart(created.fence) - const output = 'large command output\n'.repeat(2_000) - - codex.notify('item/completed', { - threadId: THREAD, - turnId: TURN, - item: { - type: 'commandExecution', - id: 'item-large-output', - command: 'print-many-lines', - status: 'completed', - exitCode: 0, - aggregatedOutput: output - } - }) - await drainStreamedEvents() - - const host = getStructuredAgentSessionHost() - const journal = ( - host as unknown as { sessions: Map } - ).sessions.get(SESSION)!.journal - const item = journal.snapshot().items.find((candidate) => candidate.body?.kind === 'tool-call') - const bounded = item?.body?.kind === 'tool-call' ? item.body.output : undefined - expect(bounded).toMatchObject({ truncated: true, byteLength: Buffer.byteLength(output) }) - }) - - it('keeps an answered prompt resolved after the provider exits', async () => { - const created = await ok<{ fence: number }>('agentSession.create', createIntentParams()) - await sendToStart(created.fence) - codex.notify('turn/started', { threadId: THREAD, turn: { id: TURN } }) - codex.notify('item/started', { - threadId: THREAD, - turnId: TURN, - item: { - type: 'commandExecution', - id: 'item-needs-answer', - command: 'build', - status: 'inProgress' - } - }) - codex.ask(9, 'item/commandExecution/requestApproval', { - threadId: THREAD, - turnId: TURN, - itemId: 'item-needs-answer', - availableDecisions: ['accept', 'decline'] - }) - await drainStreamedEvents() - const host = getStructuredAgentSessionHost() - const journal = ( - host as unknown as { sessions: Map } - ).sessions.get(SESSION)!.journal - const approval = journal.snapshot().items.find((item) => item.body?.kind === 'approval') - expect(approval?.body).toMatchObject({ - kind: 'approval', - resolution: { state: 'pending' } - }) - - await ok('agentSession.respondToApproval', { - envelope: envelope( - 'agentSession.respondTo:approval', - { - itemId: approval?.itemId, - expectedRevision: approval?.revision, - optionId: 'accept' - }, - created.fence - ), - itemId: approval?.itemId, - expectedRevision: approval?.revision, - optionId: 'accept' - }) - codex.live().handlers.onExit?.(new Error('provider exited after answer')) - await drainStreamedEvents() - - expect( - journal.snapshot().items.find((item) => item.itemId === approval?.itemId)?.body - ).toMatchObject({ - kind: 'approval', - resolution: { state: 'resolved', selectedOptionId: 'accept' } - }) - }) }) diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index e16ba68adfbf..af1dd69b66e6 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -44,6 +44,8 @@ import { } from './structured-chat-coordinator-fake-codex-fixture' import { attachForTests } from '../native-chat/agent-session-wire/structured-agent-session-attach-test-support' +const SURFACE_CALLER = { callerKey: 'test-surface' } + const COORDINATOR = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' const PEER_CHAT = '7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64' const WORKER_PANE = 'tab_worker:bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb' @@ -89,11 +91,7 @@ async function call( } async function openChat(sessionId: string): Promise { - const attached = await attachForTests( - host, - { callerKey: 'test-surface' }, - attachParams(sessionId) - ) + const attached = await attachForTests(host, SURFACE_CALLER, attachParams(sessionId)) expect(attached, JSON.stringify(attached)).toMatchObject({ ok: true }) await host.setSessionTabVisibility(sessionId, true) threadBySession.set(sessionId, codex.connections.at(-1)!.threadId!) diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index 2602a4a0845c..be8288fc0626 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -503,6 +503,7 @@ describe('cross-version structured agent sessions', () => { now: () => NOW }) setStructuredAgentSessionHost(host) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: attachParams builds the wire shape the suite feeds every build; the host takes its typed form. const attached = await host.create({ callerKey: 'test' }, attachParams(null) as never) expect(attached.ok).toBe(true) createMobileSessionTerminal = vi.fn() From cba838f7769663242c55fc50b621a754177a8393 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 13:58:14 -0700 Subject: [PATCH 04/84] refactor(native-chat): a record is founded only by its create; tests start agents the way production does The reservation no longer founds or takes over a record: a start names the fence of the record it read, a missing record refuses, and adoption and tab checks live with the at-rest create. attachForTests now runs host.create at rest, then the delivery loop's start from the stored record. attachExistingForTests keeps the attach's own guards (stale fence, replayed operation, a lease that still admits a writer) testable. Store-level suites found records through the create and reserve at its fence, so fences sit one higher, as in production. New direct tests cover the create's refusals: sessionExists, recordUnreadable, invalid args and options, conversationHeldElsewhere, and the tab it reserves. --- ...-agent-session-acquisition-options.test.ts | 125 ++++----- .../structured-agent-session-attach-flow.ts | 13 + ...tured-agent-session-attach-test-support.ts | 62 ++++- .../structured-agent-session-attach.ts | 15 +- ...-agent-session-claude-option-queue.test.ts | 1 + ...red-agent-session-claude-root-exit.test.ts | 8 +- ...ed-agent-session-conversation-stop.test.ts | 4 +- ...ured-agent-session-crash-mid-start.test.ts | 39 ++- ...tured-agent-session-crash-turn-end.test.ts | 12 +- ...ession-failed-create-owner-verdict.test.ts | 54 ++-- ...session-failed-create-sink-release.test.ts | 32 +-- .../structured-agent-session-host.test.ts | 77 +++--- ...ctured-agent-session-lease-renewer.test.ts | 8 +- ...ent-session-pre-spawn-first-answer.test.ts | 23 +- ...nt-session-processless-reservation.test.ts | 55 ++-- ...tured-agent-session-recovery-exits.test.ts | 54 ++-- ...-agent-session-recovery-resolution.test.ts | 18 +- ...uctured-agent-session-repeat-press.test.ts | 4 +- .../structured-agent-session-rewind.test.ts | 89 ++----- ...agent-session-settled-attach-retry.test.ts | 66 +++-- ...t-session-wedged-profile-migration.test.ts | 6 +- .../agent-session-at-rest-create.test.ts | 247 ++++++++++++++++++ .../runtime/agent-session-at-rest-create.ts | 88 +++++-- ...-session-conversation-clear-commit.test.ts | 8 +- ...nt-session-conversation-name-store.test.ts | 10 +- ...session-death-evidence-persistence.test.ts | 4 +- ...agent-session-launch-env-admission.test.ts | 33 ++- .../agent-session-lease-renewal.test.ts | 10 +- .../agent-session-record-options.test.ts | 8 +- ...ent-session-record-store-tab-table.test.ts | 67 ++--- ...agent-session-record-store-test-harness.ts | 44 ++++ .../agent-session-record-store.test.ts | 145 +++++----- ...ession-released-reservation-replay.test.ts | 54 ++-- ...gent-session-reservation-admission.test.ts | 214 +++------------ .../agent-session-reservation-admission.ts | 143 +--------- .../agent-session-store-transactions.test.ts | 4 +- ...sion-unsaved-creation-supersession.test.ts | 14 +- ...gent-session-record-import-install.test.ts | 4 +- ...nt-session-spawn-token-descendants.test.ts | 12 +- 39 files changed, 1010 insertions(+), 864 deletions(-) create mode 100644 src/main/runtime/agent-session-at-rest-create.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index a0d2f0fdb7dd..b8db37903085 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -6,7 +6,10 @@ import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-ses import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { + foundTestAgentSessionRecord, + openTestAgentSessionRecordStore +} from '../../runtime/agent-session-record-store-test-harness' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { digestPayload } from '../agent-session-journal/journal-payload-bounds' @@ -36,7 +39,7 @@ afterEach(async () => { function attachParams( operationId: string, - expectedRuntimeFence: number | null, + expectedRuntimeFence: number, options?: Readonly> ): AgentSessionAttachParams { const params: AgentSessionAttachParams = { @@ -106,6 +109,25 @@ function adapter(input: { } } +/** The store with the record a create founds at rest (fence 1), which the first start reserves. */ +async function foundedStore( + directory: string, + options?: Readonly> +): Promise { + const store = await openTestAgentSessionRecordStore(directory) + const { location, provider, accountHome } = attachParams(CREATE_OPERATION, 1) + await foundTestAgentSessionRecord(store, { + sessionId: SESSION, + location, + provider, + accountHome, + ...(options ? { options } : {}), + claimKeyId: 'key-1', + now: NOW + }) + return store +} + function expectSettledAttachLease(record: AgentSessionRecord | null): void { expect(record).not.toBeNull() const lease = record!.lease @@ -117,7 +139,7 @@ function expectSettledAttachLease(record: AgentSessionRecord | null): void { describe('structured session acquisition options', () => { it('samples provider history before acquiring a replacement child', async () => { root = await mkdtemp(join(tmpdir(), 'orca-history-before-acquire-')) - const initialStore = await openTestAgentSessionRecordStore(root) + const initialStore = await foundedStore(root) let childAcquired = false const historyWindow = (): ProviderHistoryWindow => ({ items: [], @@ -161,7 +183,7 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null), + params: attachParams(CREATE_OPERATION, 1), now: () => NOW, onAttached: (attached) => { firstJournal = attached.journal @@ -176,7 +198,7 @@ describe('structured session acquisition options', () => { role: 'user', blocks: [{ type: 'text', text: 'deploy the thing' }] } satisfies AgentJournalMessageItem, - fence: 1 + fence: 2 }) await firstJournal!.close() const store = await openTestAgentSessionRecordStore(root) @@ -213,9 +235,9 @@ describe('structured session acquisition options', () => { it('persists create defaults before the first provider acquisition', async () => { root = await mkdtemp(join(tmpdir(), 'orca-create-options-')) - const store = await openTestAgentSessionRecordStore(root) - const sessionAdapter = adapter({ origin: 'created' }) const options = { model: 'gpt-5.6-sol', effort: 'medium', fastMode: 'false' } + const store = await foundedStore(root, options) + const sessionAdapter = adapter({ origin: 'created' }) const recordPhase = vi.fn() const created = await performAttach({ @@ -229,7 +251,7 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null, options), + params: attachParams(CREATE_OPERATION, 1, options), now: () => NOW, recordPhase, onAttached: () => {} @@ -242,9 +264,9 @@ describe('structured session acquisition options', () => { expect(store.getRecord(SESSION)?.options).toEqual(options) }) - it('replays a create retried after the host re-resolved different options', async () => { + it('replays a start retried after the host re-resolved different options', async () => { root = await mkdtemp(join(tmpdir(), 'orca-create-retry-')) - const store = await openTestAgentSessionRecordStore(root) + const store = await foundedStore(root, { model: 'gpt-5.6-sol', effort: 'medium' }) const sessionAdapter = adapter({ origin: 'created' }) const attempt = async (options: Readonly>, spawnToken: string) => performAttach({ @@ -258,13 +280,13 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null, options), + params: attachParams(CREATE_OPERATION, 1, options), now: () => NOW, onAttached: () => {} }) const created = await attempt({ model: 'gpt-5.6-sol', effort: 'medium' }, 'spawn-a') - // Why: the user may reselect a model between an unknown-outcome create and the + // Why: the user may reselect a model between an unknown-outcome attempt and the // retry that reuses its operation id; the retry must replay, not conflict. const retried = await attempt({ model: 'gpt-5.5', effort: 'high' }, 'spawn-b') @@ -275,7 +297,7 @@ describe('structured session acquisition options', () => { it('persists provider options before proving a resumed legacy record', async () => { root = await mkdtemp(join(tmpdir(), 'orca-acquisition-options-')) - const store = await openTestAgentSessionRecordStore(root) + const store = await foundedStore(root) const created = await performAttach({ store, @@ -288,7 +310,7 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null), + params: attachParams(CREATE_OPERATION, 1), now: () => NOW, onAttached: () => {} }) @@ -342,7 +364,7 @@ describe('structured session acquisition options', () => { it('clears a rejected Fast restore instead of retaining the prior encoded value', async () => { root = await mkdtemp(join(tmpdir(), 'orca-acquisition-fast-restore-')) - const store = await openTestAgentSessionRecordStore(root) + const store = await foundedStore(root, { model: 'gpt-standard', fastMode: 'true' }) const sessionAdapter = adapter({ origin: 'created', options: { current: { model: 'gpt-standard' }, models: [] }, @@ -360,7 +382,7 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null, { + params: attachParams(CREATE_OPERATION, 1, { model: 'gpt-standard', fastMode: 'true' }), @@ -374,7 +396,7 @@ describe('structured session acquisition options', () => { it('releases an acquisition when provider options cannot be read', async () => { root = await mkdtemp(join(tmpdir(), 'orca-acquisition-options-failure-')) - const store = await openTestAgentSessionRecordStore(root) + const store = await foundedStore(root) const releaseAcquisition = vi.fn(async () => true) const failingAdapter: StructuredAgentSessionAdapter = { ...adapter({ origin: 'created' }), @@ -396,7 +418,7 @@ describe('structured session acquisition options', () => { probe: { outcome: 'reservation-unused' } }, callerKey: 'client-1', - params: attachParams(CREATE_OPERATION, null), + params: attachParams(CREATE_OPERATION, 1), now: () => NOW, onAttached: () => {} }) @@ -425,7 +447,7 @@ describe('structured session acquisition options', () => { ] as const)('atomically settles the lease and operation after %s', async (_case, cleanup) => { const exitProven = cleanup === true root = await mkdtemp(join(tmpdir(), `orca-acquisition-${failurePoint}-`)) - const store = await openTestAgentSessionRecordStore(root) + const store = await foundedStore(root) const base = adapter({ origin: 'created', options: { current: { model: 'gpt-5.6-terra' }, models: [] } @@ -473,11 +495,7 @@ describe('structured session acquisition options', () => { ? { historyFilePath: vi.fn().mockRejectedValueOnce(injected).mockResolvedValue(null) } : {}) } - const perform = ( - target: AgentSessionRecordStore, - operationId: string, - fence: number | null - ) => + const perform = (target: AgentSessionRecordStore, operationId: string, fence: number) => performAttach({ store: target, adapter: failingAdapter, @@ -499,7 +517,7 @@ describe('structured session acquisition options', () => { // A proven exit before the journal opens is answered once, as the refusal its replay gives; // no exit was observed, so it names no situation. - const failed = perform(store, CREATE_OPERATION, null) + const failed = perform(store, CREATE_OPERATION, 1) await (exitProven && failurePoint !== 'journal' ? expect(failed).resolves.toEqual({ ok: false, @@ -531,26 +549,26 @@ describe('structured session acquisition options', () => { if (exitProven) { expect(failedRecord?.lease).toMatchObject({ - runtimeFence: 2, + runtimeFence: 3, claimStatus: 'released', handoffStage: null, handoffOperationId: null, ownerProcess: null, reservedSpawnToken: null }) - await expect(perform(reopened, RESUME_OPERATION, 2)).resolves.toMatchObject({ ok: true }) + await expect(perform(reopened, RESUME_OPERATION, 3)).resolves.toMatchObject({ ok: true }) expectSettledAttachLease(reopened.getRecord(SESSION)) } else if (failurePoint === 'proof' || failurePoint === 'journal') { // A recorded owner goes to recovery, which concludes about it before the next start. expect(failedRecord?.lease).toMatchObject({ - runtimeFence: 1, + runtimeFence: 2, claimStatus: failurePoint === 'journal' ? 'live' : 'reserved', handoffStage: 'recovering', // The settled operation must not stay named by the lease as an in-flight transfer. handoffOperationId: null, reservedSpawnToken: 'spawn-a' }) - await expect(perform(reopened, RESUME_OPERATION, 1)).resolves.toMatchObject({ + await expect(perform(reopened, RESUME_OPERATION, 2)).resolves.toMatchObject({ ok: false, refusal: { code: 'agent_session_ownership_unknown' } }) @@ -558,7 +576,7 @@ describe('structured session acquisition options', () => { // No owner was recorded, and the adapter closed the stdio of anything it spawned: released, // with no death evidence, since nothing proved one. expect(failedRecord?.lease).toMatchObject({ - runtimeFence: 2, + runtimeFence: 3, claimStatus: 'released', handoffStage: null, handoffOperationId: null, @@ -566,53 +584,8 @@ describe('structured session acquisition options', () => { reservedSpawnToken: null, deathEvidence: null }) - await expect(perform(reopened, RESUME_OPERATION, 2)).resolves.toMatchObject({ ok: true }) + await expect(perform(reopened, RESUME_OPERATION, 3)).resolves.toMatchObject({ ok: true }) } }) }) }) - -describe('the tab a create reserves', () => { - async function openStore() { - root = await mkdtemp(join(tmpdir(), 'orca-surface-tab-id-')) - return openTestAgentSessionRecordStore(root) - } - - function attachWith(store: AgentSessionRecordStore, surfaceTabId?: string) { - return performAttach({ - store, - adapter: adapter({ origin: 'created' }), - openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), - authority: { - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: CREATE_OPERATION, - probe: { outcome: 'reservation-unused' } - }, - callerKey: 'client-1', - // Beside the fingerprinted fields, like `options`: which tab shows the chat is not which - // conversation this attaches to. - params: { - ...attachParams(CREATE_OPERATION, null), - ...(surfaceTabId ? { surfaceTabId } : {}) - }, - now: () => NOW, - onAttached: () => {} - }) - } - - it('takes no tab at attach, then answers a retry naming another tab with the one it was given', async () => { - const store = await openStore() - const created = await attachWith(store, 'chat-tab-1') - // Publishing the tab takes the id, so a create that never gets there leaves nothing behind. - expect(created.ok && created.value.tabId).toBeUndefined() - expect(store.getSessionTabId(SESSION)).toBeNull() - - await store.setSessionTabVisibility(SESSION, true, 'chat-tab-1') - expect(await attachWith(store, 'chat-tab-2')).toMatchObject({ - ok: true, - value: { tabId: 'chat-tab-1' } - }) - expect(store.getSessionTabId(SESSION)).toBe('chat-tab-1') - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index 838992305c2e..a0c87ca16aa5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -87,6 +87,18 @@ export async function performAttach( ) }) const sessionId = params.envelope.sessionId + const expectedFence = params.envelope.expectedRuntimeFence + if (expectedFence === null) { + // A record is founded only by its create; a start names the fence of the record it read. + return { + ok: false, + refusal: refuse( + 'agent_session_operation_invalid', + { reason: 'requestMalformed' }, + 'An agent start names the fence of the session record it starts.' + ) + } + } const admitted = admitAttachOrRefuse(params) if (!admitted.ok) { return admitted @@ -110,6 +122,7 @@ export async function performAttach( reserveRequestFor({ sessionId, params, + expectedFence, authority: input.authority, callerKey: input.callerKey, fingerprint: admitted.fingerprint, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-support.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-support.ts index 8c58c2b08acd..4e861b0a6bd7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-support.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-support.ts @@ -1,16 +1,72 @@ -// For a test that needs a running agent now: the attach an agent start makes, under the session's -// serialize, as the delivery loop runs it. A create starts nothing; its first message does. +// For a test that needs a running agent now, the way production gives a chat one: a create at rest +// when the params name no record yet (a null fence), then the start its first message makes from +// the stored record, at the fence it reads there. Answers in the attach's shape for the fence and page. import type { AgentSessionAttachResult, AgentSessionMutationResult } from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { ensureStructuredAgentSessionAgent } from './structured-agent-session-agent-start' import { attachStructuredAgentSessionUnderSerialize } from './structured-agent-session-attach-orchestration' +import { readAgentSessionHydrationPage } from './agent-session-history-page' import type { StructuredAgentSessionHost } from './structured-agent-session-host' import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types' -export function attachForTests( +export async function attachForTests( + host: StructuredAgentSessionHost, + caller: StructuredAgentSessionCaller, + params: AgentSessionAttachParams +): Promise> { + const sessionId = params.envelope.sessionId + if (params.envelope.expectedRuntimeFence === null) { + const created = await host.create(caller, params) + if (!created.ok) { + return created + } + } + return startAgentForTests(host, sessionId) +} + +/** The delivery loop's start, as its first message would make it. */ +export function startAgentForTests( + host: StructuredAgentSessionHost, + sessionId: string +): Promise> { + const { attachContext, serialize, sessions } = host.collaboratorsForTests() + return serialize(sessionId, async () => { + const started = await ensureStructuredAgentSessionAgent(attachContext(), sessionId) + if (!started.ok) { + return { ok: false, refusal: started.refusal } + } + const journal = sessions.get(sessionId)?.journal + const fence = host.deps.store.getRecord(sessionId)?.lease.runtimeFence + if (!journal || fence === undefined) { + throw new Error(`attachForTests: ${sessionId} started without an open conversation`) + } + const tabId = host.deps.store.getSessionTabId(sessionId) + return { + ok: true, + replayed: false, + fence, + cursor: journal.cursor(), + value: { + sessionId, + fence, + page: readAgentSessionHydrationPage(journal, fence), + unconfirmedClientMessageIds: [], + ...(tabId ? { tabId } : {}) + } + } + }) +} + +/** + * The attach itself at the fence the caller names, for tests of its own guards (a stale fence, a + * lease that still admits a writer, the drain before a replacement acquires). The production start + * never presents those: it reads the record under the same serialize and skips a running agent. + */ +export function attachExistingForTests( host: StructuredAgentSessionHost, caller: StructuredAgentSessionCaller, params: AgentSessionAttachParams diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index 9dd69020054e..9320c2653c57 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -1,9 +1,6 @@ -// Attach: reserve the session record, then open its journal. -// -// `create` and `ensure` are the same transition with a different starting -// point — a null expected fence means "no session exists yet". Both go through -// the record store's compare-and-swap, which also owns the idempotency row, so -// a retried attach replays instead of reserving a second owner. +// Attach: reserve an existing session record for an agent start, then open its journal. A record is +// founded only by its create (`structured-agent-session-create-at-rest`); the attach goes through the +// record store's compare-and-swap at the fence the start read. import type { AgentSessionJournalIdentity, @@ -289,6 +286,7 @@ export function adoptedProviderHandleLink( export function reserveRequestFor(input: { sessionId: string params: AgentSessionAttachParams + expectedFence: number authority: AgentSessionAttachAuthority callerKey: string fingerprint: string @@ -301,12 +299,9 @@ export function reserveRequestFor(input: { provider: params.provider, accountHome: params.accountHome, ...(params.options ? { options: params.options } : {}), - ...(params.envelope.expectedRuntimeFence === null && params.surfaceTabId - ? { surfaceTabId: params.surfaceTabId } - : {}), ...(authority.launchArgs ? { launchArgs: authority.launchArgs } : {}), ...(authority.launchEnv ? { launchEnv: authority.launchEnv } : {}), - expectedFence: params.envelope.expectedRuntimeFence, + expectedFence: input.expectedFence, spawnToken: authority.spawnToken, claimKeyId: authority.claimKeyId, handoffOperationId: authority.handoffOperationId, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts index b84eb73c7107..68198911d1dd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts @@ -63,6 +63,7 @@ function adapter(): StructuredAgentSessionAdapter { } }) return { + supportsCreate: (_location, agent) => agent === 'claude', acquire, dispatch: vi.fn(), cancelTurn: vi.fn(async () => ({ cancelled: true })), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts index 778bd3cc2fa9..fa383529d897 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts @@ -8,7 +8,10 @@ import { fakeClaude, identityFor } from '../../claude/claude-structured-session-test-support' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { + foundAndReserveTestAgentSessionRecord, + openTestAgentSessionRecordStore +} from '../../runtime/agent-session-record-store-test-harness' import { createTrackedJournalOpener, openTestJournalHostDatabase @@ -37,7 +40,7 @@ describe('Claude root-exit stop', () => { unprovenCloseVerdict: { root: 'exited', tree: 'unverifiable' } }) const adapter = adapterFor(claude) - const reservation = await store.reserveOwner({ + const reservation = await foundAndReserveTestAgentSessionRecord(store, { sessionId: 'session-1', location: { executionHostId: 'local', @@ -47,7 +50,6 @@ describe('Claude root-exit stop', () => { }, provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: root }, - expectedFence: null, spawnToken: 'spawn-1', claimKeyId: 'key-1', handoffOperationId: null, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index 81d3b30cbf33..1f1d2fbc09c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -176,13 +176,13 @@ describe('a Stop that names no turn', () => { expect(stopped).toEqual({ ok: true, replayed: false, - fence: 1, + fence: 2, cursor: expect.anything(), value: { cancelled: true } }) expect(cancelTurn).toHaveBeenCalledTimes(1) expect(cancelTurn.mock.calls[0]![0]).not.toHaveProperty('turnId') - expect(cancelTurn.mock.calls[0]![0]).toMatchObject({ sessionId: SESSION, fence: 1 }) + expect(cancelTurn.mock.calls[0]![0]).toMatchObject({ sessionId: SESSION, fence: 2 }) expect(await statusRows()).toEqual(['Cancellation requested.']) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts index 536721fadf07..a8a1cc229221 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts @@ -25,7 +25,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { attachForTests } from './structured-agent-session-attach-test-support' +import { attachForTests, startAgentForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } const CHILD_PID = 4321 @@ -159,22 +159,22 @@ describe('a host that dies while its Codex child is starting', () => { }) }) -// The client keeps a create it never heard back from and retries it under the same operation id, so -// that replay, not a fresh start, is what the user's Retry and first send go through. -describe('a create replayed after the host that ran it died', () => { +// The create committed at rest before its first start ran, so the client's retry of a create it +// never heard back from replays that chat, and the next message's start is what starts an agent. +describe('a first start cut short by the host dying', () => { const PAST_OPERATION_EXPIRY = AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS + 60_000 it.each([ ['its child was recorded', adapterThatNeverFinishesStarting, 0], [ - 'its child was recorded, and its operation row has since expired', + 'its child was recorded, and its operation rows have since expired', adapterThatNeverFinishesStarting, PAST_OPERATION_EXPIRY ], ['nothing was recorded beyond the reservation', adapterThatNeverSpawns, 0], [ - 'nothing was recorded, and its operation row has since expired', + 'nothing was recorded, and its operation rows have since expired', adapterThatNeverSpawns, PAST_OPERATION_EXPIRY ] @@ -212,28 +212,27 @@ describe('a create replayed after the host that ran it died', () => { expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, - runtimeFence: 2 + runtimeFence: 3 }) + if (elapsedMs === 0) { + // A retried create replays the chat it made, at rest; it starts nothing. + expect(await relaunched.create(CALLER, params)).toMatchObject({ ok: true, replayed: true }) + expect(restarted.connections).toHaveLength(0) + } - const replayed = await attachForTests(relaunched, CALLER, params) - // Before, `agent_session_ownership_unknown` while the row was pending, then `_operation_expired`. - expect(replayed.ok ? null : replayed.refusal.code).toBeNull() - expect(replayed).toMatchObject({ ok: true, value: { sessionId: SESSION, fence: 3 } }) + expect(await startAgentForTests(relaunched, SESSION)).toMatchObject({ + ok: true, + value: { sessionId: SESSION, fence: 4 } + }) expect(restarted.connections).toHaveLength(1) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', - runtimeFence: 3, + runtimeFence: 4, ownerProcess: { spawnToken: 'spawn-b' } }) - expect( - store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId)?.outcome - ).toEqual({ status: 'succeeded', sessionId: SESSION }) - // Settled now: the same id replays that answer and never starts a second agent. - await expect(attachForTests(relaunched, CALLER, params)).resolves.toMatchObject({ - ok: true, - replayed: true - }) + // The next message finds the agent running and never starts a second one. + await expect(startAgentForTests(relaunched, SESSION)).resolves.toMatchObject({ ok: true }) expect(restarted.connections).toHaveLength(1) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index e203abc57560..8cd443c8392f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -535,13 +535,13 @@ describe('a turn a newer start could not settle before it failed', () => { lastRenewedAt: RELAUNCHED_AT }) advance(60_000) - // Recovery records that child's death, then the client retries at the fence it was told. - await expect(attach(15)).resolves.toMatchObject({ refusal: { currentFence: 16 } }) - expect(store.getRecord(SESSION)?.lease.deathEvidence).toMatchObject({ - ownerFence: 15, - lastProvenAliveAt: RELAUNCHED_AT + // Recovery records that child's death; the next start then reserves past it. + await host.collaboratorsForTests().runtimeState.resolveRecovery(SESSION) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + runtimeFence: 16, + deathEvidence: { ownerFence: 15, lastProvenAliveAt: RELAUNCHED_AT } }) - await expect(attach(16)).resolves.toMatchObject({ ok: true }) + await expect(attach(16)).resolves.toMatchObject({ ok: true, fence: 17 }) expect(await settledTurn()).toEqual(UNVERIFIABLE_TURN) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts index adcfba2cab05..2e8608fbb8d0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts @@ -1,5 +1,5 @@ -// A durably failed create tells a client what the host proved about the provider process, so a -// client can tell "retry under a new operation" (exited) from "the session may exist" (anything else). +// A failed first start says what the host proved about the provider process, so a reader can tell +// "the next start goes ahead" (exited) from "the process may still exist" (anything else). import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -23,12 +23,12 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { attachForTests } from './structured-agent-session-attach-test-support' +import { attachForTests, startAgentForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'claude stream-json exited (code 1): stderr tail' // The refusal says what the chat's start failure says; the error text stays in the log. -const COULD_NOT_RESTART = "Codex couldn't restart. Send your message to try again." +const COULD_NOT_START = "Codex couldn't start. Send your message to try again." const PROVIDER_STOPPED = 'Codex stopped before it finished starting. Send your message to try again.' @@ -73,10 +73,10 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -describe('failed create owner verdict', () => { +describe('failed first start owner verdict', () => { it.each([ // The cleanup's release proves the whole tree gone, which says nothing about why it failed. - ['a failure the cleanup proved gone', () => new Error(EXIT_REASON), {}, COULD_NOT_RESTART], + ['a failure the cleanup proved gone', () => new Error(EXIT_REASON), {}, COULD_NOT_START], // Only an exit the adapter saw says the provider stopped. [ 'an exit the adapter observed', @@ -85,12 +85,10 @@ describe('failed create owner verdict', () => { PROVIDER_STOPPED ] ])( - 'answers %s as exited on the first call and its replay, and a new operation starts fresh', + 'answers %s as exited, and the next start goes ahead', async (_case, failure, situation, message) => { acquire.mockRejectedValueOnce(failure()) - const first = hostTestAttachParams(null) - // The replay names the same details as the first answer: the ledger kept them beside the code, - // and the verdict reaches released clients at the top level exactly as before. + // The verdict reaches released clients at the top level exactly as before. const refusal = { code: 'agent_session_operation_invalid', details: { ...situation, ownerVerdict: 'exited' }, @@ -98,13 +96,13 @@ describe('failed create owner verdict', () => { ownerVerdict: 'exited' } - await expect(attachForTests(host, CALLER, first)).resolves.toEqual({ ok: false, refusal }) - await expect(attachForTests(host, CALLER, first)).resolves.toEqual({ ok: false, refusal }) + await expect(attachForTests(host, CALLER, hostTestAttachParams(null))).resolves.toEqual({ + ok: false, + refusal + }) expect(acquire).toHaveBeenCalledOnce() - const retry = hostTestAttachParams(null) - expect(retry.envelope.clientOperationId).not.toBe(first.envelope.clientOperationId) - await expect(attachForTests(host, CALLER, retry)).resolves.toMatchObject({ ok: true }) + await expect(startAgentForTests(host, SESSION)).resolves.toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') } @@ -112,7 +110,7 @@ describe('failed create owner verdict', () => { it.each([ // A cleanup that saw the root go may have stopped it itself. - ['a root exit the cleanup saw', () => new Error(EXIT_REASON), {}, COULD_NOT_RESTART], + ['a root exit the cleanup saw', () => new Error(EXIT_REASON), {}, COULD_NOT_START], [ 'a root exit the adapter observed', () => withObservedProviderExit(new Error(EXIT_REASON)), @@ -120,12 +118,9 @@ describe('failed create owner verdict', () => { PROVIDER_STOPPED ] ])( - 'answers %s as exited on the first call, in the shape its replay takes', + 'answers %s as exited, and the next start goes ahead', async (_case, cause, situation, message) => { acquire.mockRejectedValueOnce(new AgentSessionAcquisitionRootExitObservedError(cause())) - const first = hostTestAttachParams(null) - // The replay names the same details as the first answer: the ledger kept them beside the code, - // and the verdict reaches released clients at the top level exactly as before. const refusal = { code: 'agent_session_operation_invalid', details: { ...situation, ownerVerdict: 'exited' }, @@ -133,15 +128,13 @@ describe('failed create owner verdict', () => { ownerVerdict: 'exited' } - await expect(attachForTests(host, CALLER, first)).resolves.toEqual({ ok: false, refusal }) - await expect(attachForTests(host, CALLER, first)).resolves.toEqual({ ok: false, refusal }) + await expect(attachForTests(host, CALLER, hostTestAttachParams(null))).resolves.toEqual({ + ok: false, + refusal + }) expect(acquire).toHaveBeenCalledOnce() - await expect(attachForTests(host, CALLER, hostTestAttachParams(null))).resolves.toMatchObject( - { - ok: true - } - ) + await expect(startAgentForTests(host, SESSION)).resolves.toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledTimes(2) } ) @@ -162,12 +155,9 @@ describe('failed create owner verdict', () => { it('never claims exited when the failed attempt could not prove its process gone', async () => { acquire.mockRejectedValueOnce(new AgentSessionAcquisitionExitUnprovenError(new Error('hung'))) - const first = hostTestAttachParams(null) + const answer = await attachForTests(host, CALLER, hostTestAttachParams(null)) - await expect(attachForTests(host, CALLER, first)).rejects.toThrow() - const replay = await attachForTests(host, CALLER, first) - - expect(replay).toMatchObject({ + expect(answer).toMatchObject({ ok: false, refusal: { code: 'agent_session_ownership_unknown', ownerVerdict: 'unverifiable' } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts index e3ae13972a83..fe82d9611d09 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts @@ -21,7 +21,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { attachForTests } from './structured-agent-session-attach-test-support' +import { attachForTests, startAgentForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'claude stream-json exited (code 1): claude: not signed in' @@ -68,14 +68,14 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -describe('a create that fails after its child wrote through the unbound sink', () => { +describe('a first start that fails after its child wrote through the unbound sink', () => { it.each([ // The common failed start: answered as a refusal. ['refused', new Error(EXIT_REASON)], - // A failure the attach cannot classify still throws, and must release the sink too. + // A failure the attach throws, settled before it does, which the start answers from. ['thrown', new AgentSessionPreSpawnError(new Error(EXIT_REASON))] ])( - 'releases the sink when %s, so a new create and shutdown both proceed', + 'releases the sink when %s, so the next start and shutdown both proceed', async (_how, cause) => { acquire.mockImplementationOnce(async ({ events }) => { // The published child's exit reached the translator before any journal was attached. @@ -83,19 +83,12 @@ describe('a create that fails after its child wrote through the unbound sink', ( throw cause }) - const failed = attachForTests(host, CALLER, hostTestAttachParams(null)) - await (cause instanceof AgentSessionPreSpawnError - ? expect(failed).rejects.toThrow("Codex couldn't restart. Send your message to try again.") - : expect(failed).resolves.toMatchObject({ - ok: false, - refusal: { message: "Codex couldn't restart. Send your message to try again." } - })) + expect(await attachForTests(host, CALLER, hostTestAttachParams(null))).toMatchObject({ + ok: false, + refusal: { message: "Codex couldn't start. Send your message to try again." } + }) - await expect(attachForTests(host, CALLER, hostTestAttachParams(null))).resolves.toMatchObject( - { - ok: true - } - ) + await expect(startAgentForTests(host, SESSION)).resolves.toMatchObject({ ok: true }) await expect(host.flushAllStreamedEvents()).resolves.toBeUndefined() expect(acquire).toHaveBeenCalledTimes(2) } @@ -124,10 +117,9 @@ describe('a create that fails after its child wrote through the unbound sink', ( }) // The session stays indexed across this failure: it is a resume, not a create. - const failed = attachForTests(host, CALLER, hostTestAttachParams(releasedFence)) - await (cause instanceof AgentSessionPreSpawnError - ? expect(failed).rejects.toThrow("Codex couldn't restart. Send your message to try again.") - : expect(failed).resolves.toMatchObject({ ok: false })) + expect(await attachForTests(host, CALLER, hostTestAttachParams(releasedFence))).toMatchObject({ + ok: false + }) const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 await expect(attachForTests(host, CALLER, hostTestAttachParams(fence))).resolves.toMatchObject({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index b184b33231ca..8190e16aa3bf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -28,7 +28,11 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { attachForTests } from './structured-agent-session-attach-test-support' +import { + attachExistingForTests, + attachForTests, + startAgentForTests +} from './structured-agent-session-attach-test-support' let root: string let store: AgentSessionRecordStore @@ -65,7 +69,7 @@ describe('attach', () => { it('refuses a payload the client fingerprinted wrong', async () => { const params = attachParams() - const result = await attachForTests(host, CALLER, { + const result = await host.create(CALLER, { ...params, envelope: { ...params.envelope, payloadFingerprint: 'a'.repeat(64) } }) @@ -80,7 +84,7 @@ describe('attach', () => { providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: null } }) - expect(await attachForTests(host, CALLER, params)).toMatchObject({ + expect(await host.create(CALLER, params)).toMatchObject({ ok: false, refusal: { code: 'agent_session_operation_invalid' } }) @@ -89,14 +93,18 @@ describe('attach', () => { it('refuses a second create against a live session', async () => { await attach() - expect(await attachForTests(host, CALLER, attachParams())).toMatchObject({ ok: false }) + expect(await host.create(CALLER, attachParams())).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_conflict', details: { reason: 'sessionExists' } } + }) }) - it('replays a retried attach instead of reserving a second owner', async () => { + it('replays a retried create instead of founding a second record', async () => { const params = attachParams() - await attachForTests(host, CALLER, params) - const retry = await attachForTests(host, CALLER, params) - expect(retry).toMatchObject({ ok: true, replayed: true }) + expect(await host.create(CALLER, params)).toMatchObject({ ok: true, replayed: false }) + const retry = await host.create(CALLER, params) + expect(retry).toMatchObject({ ok: true, replayed: true, fence: 1 }) + expect(acquire).not.toHaveBeenCalled() }) it('retires a failed proved acquisition before admitting a fresh operation', async () => { @@ -148,16 +156,14 @@ describe('attach', () => { refusal: { code: 'agent_session_operation_invalid', details: { ownerVerdict: 'exited' }, - message: "Codex couldn't restart. Send your message to try again.", + message: "Codex couldn't start. Send your message to try again.", ownerVerdict: 'exited' } } expect(await attachForTests(host, CALLER, params)).toEqual(refused) - expect(await attachForTests(host, CALLER, params)).toEqual(refused) - const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - expect(await attachForTests(host, CALLER, ensureParams(releasedFence))).toMatchObject({ - ok: true - }) + // The failure is the start's: the create it followed still replays as the chat it made. + expect(await host.create(CALLER, params)).toMatchObject({ ok: true, replayed: true }) + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('live') expect(releaseAcquisition).toHaveBeenCalledWith({ sessionId: SESSION }) @@ -169,7 +175,7 @@ describe('attach', () => { await expect(attachForTests(host, CALLER, attachParams())).resolves.toMatchObject({ ok: false, refusal: { - message: "Codex couldn't restart. Send your message to try again.", + message: "Codex couldn't start. Send your message to try again.", ownerVerdict: 'exited' } }) @@ -202,7 +208,11 @@ describe('attach', () => { now: NOW }) - const replacement = attachForTests(host, CALLER, ensureParams(released.lease.runtimeFence)) + const replacement = attachExistingForTests( + host, + CALLER, + ensureParams(released.lease.runtimeFence) + ) await new Promise((resolve) => setImmediate(resolve)) expect(acquire).toHaveBeenCalledTimes(1) @@ -577,26 +587,14 @@ describe('restart', () => { replaceHostTestState({ store, host }) } - /** The refusal a restarted host owes a client holding the dead generation's - * fence: stale, with the live fence attached so the retry can succeed. */ - async function staleFenceFrom(held: number): Promise { - const refused = await attachForTests(host, CALLER, ensureParams(held)) - if (refused.ok) { - throw new Error('a fence from the previous host generation was accepted') - } - expect(refused.refusal.code).toBe('agent_session_checkpoint_stale') - const current = refused.refusal.currentFence - expect(current).toBeGreaterThan(held) - return current ?? 0 - } - it('adjudicates the leases it loaded before deciding who may write', async () => { const before = await attach() const held = before?.lease.runtimeFence ?? 0 await reboot(async () => ({ outcome: 'pid-absent' })) - const reattached = await attachForTests(host, CALLER, ensureParams(await staleFenceFrom(held))) - expect(reattached).toMatchObject({ ok: true }) + // The start reads the fence the restart's eviction moved to, never the dead generation's. + const reattached = await startAgentForTests(host, SESSION) + expect(reattached).toMatchObject({ ok: true, fence: held + 2 }) expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(false) expect(store.getRecord(SESSION)?.lease.ownerProcess?.pid).toBe(4242) }) @@ -706,11 +704,7 @@ describe('restart', () => { ) acquire.mockClear() - expect( - await attachForTests(host, CALLER, ensureParams(await staleFenceFrom(held))) - ).toMatchObject({ - ok: true - }) + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: true, fence: held + 2 }) expect(acquire).toHaveBeenCalledOnce() // An unverifiable pid may already belong to an unrelated process. expect(stopOwnerProcess).not.toHaveBeenCalled() @@ -725,9 +719,9 @@ describe('restart', () => { .mockResolvedValue({ outcome: 'pid-absent' }) await reboot(probe) - await expect(attachForTests(host, CALLER, ensureParams(held))).rejects.toThrow('probe exploded') - const reattached = await attachForTests(host, CALLER, ensureParams(await staleFenceFrom(held))) - expect(reattached).toMatchObject({ ok: true }) + await expect(startAgentForTests(host, SESSION)).rejects.toThrow('probe exploded') + const reattached = await startAgentForTests(host, SESSION) + expect(reattached).toMatchObject({ ok: true, fence: held + 2 }) expect(probe).toHaveBeenCalledTimes(2) }) }) @@ -830,7 +824,8 @@ describe('subscribe', () => { emit: (event) => events.push(event), cursor: { epoch: 'epoch-from-a-previous-life', sequence: 3 } }) - expect(events[0]).toMatchObject({ type: 'reset', reset: 'epoch_changed', fence: 1 }) + // The create founded the record at fence 1; its first start moved it to 2. + expect(events[0]).toMatchObject({ type: 'reset', reset: 'epoch_changed', fence: 2 }) }) it('publishes the replacement fence when the owner generation changes', async () => { @@ -848,7 +843,7 @@ describe('subscribe', () => { now: NOW }) - const replacement = await attachForTests( + const replacement = await attachExistingForTests( host, CALLER, ensureParams(released.lease.runtimeFence) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts index 9399e4d0242b..05accad7796c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts @@ -7,7 +7,10 @@ import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { + foundAndReserveTestAgentSessionRecord, + openTestAgentSessionRecordStore +} from '../../runtime/agent-session-record-store-test-harness' import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer' const NOW = 1_800_000_000_000 @@ -17,7 +20,7 @@ async function liveStore(): Promise { const root = await mkdtemp(join(tmpdir(), 'orca-lease-renewer-')) roots.push(root) const store = await openTestAgentSessionRecordStore(root) - const reserved = await store.reserveOwner({ + const reserved = await foundAndReserveTestAgentSessionRecord(store, { sessionId: 'session-renewal', location: { executionHostId: 'local', @@ -27,7 +30,6 @@ async function liveStore(): Promise { }, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: root }, - expectedFence: null, spawnToken: 'spawn-renewal', claimKeyId: 'key-1', handoffOperationId: null, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts index db9406785f87..e0b9851eab72 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -3,7 +3,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { + foundTestAgentSessionRecord, + openTestAgentSessionRecordStore +} from '../../runtime/agent-session-record-store-test-harness' import { mapRuntimeError } from '../../runtime/rpc/errors' import { AgentSessionPreSpawnError, @@ -31,12 +34,13 @@ afterEach(async () => { root = null }) -function createParams(): AgentSessionAttachParams { +/** The first start of a chat its create founded at fence 1. */ +function startParams(): AgentSessionAttachParams { const params: AgentSessionAttachParams = { envelope: { sessionId: SESSION, clientOperationId: OPERATION, - expectedRuntimeFence: null, + expectedRuntimeFence: 1, payloadFingerprint: '' }, location: { @@ -67,6 +71,15 @@ function createParams(): AgentSessionAttachParams { async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { root = await mkdtemp(join(tmpdir(), 'orca-pre-spawn-first-answer-')) const store = await openTestAgentSessionRecordStore(root) + const { location, provider, accountHome } = startParams() + await foundTestAgentSessionRecord(store, { + sessionId: SESSION, + location, + provider, + accountHome, + claimKeyId: 'key-1', + now: NOW + }) const unused = async (): Promise => { throw new Error('not reached before a spawn') } @@ -91,7 +104,7 @@ async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { probe: { outcome: 'reservation-unused' as const } }, callerKey: 'client-1', - params: createParams(), + params: startParams(), now: () => NOW, onAttached: () => {} } @@ -106,7 +119,7 @@ async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { } } -describe('a create that fails before any process spawns', () => { +describe('a first start that fails before any process spawns', () => { it.each<[string, string, AgentSessionPreSpawnReason | undefined, string]>([ [ 'the managed account env override', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts index 9df12ea96fde..b2ca13f6edf6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -3,7 +3,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { + foundTestAgentSessionRecord, + openTestAgentSessionRecordStore +} from '../../runtime/agent-session-record-store-test-harness' import { AgentSessionPreSpawnError, type StructuredAgentSessionAdapter @@ -29,10 +32,8 @@ afterEach(async () => { root = null }) -function attachParams( - operationId = OPERATION, - expectedRuntimeFence: number | null = null -): AgentSessionAttachParams { +/** A start of the record `foundedStore` founds at fence 1. */ +function attachParams(operationId = OPERATION, expectedRuntimeFence = 1): AgentSessionAttachParams { const params: AgentSessionAttachParams = { envelope: { sessionId: SESSION, @@ -65,10 +66,30 @@ function attachParams( } } +/** The store with the record a create founds at rest, which every start here reserves. */ +async function foundedStore(directory: string) { + const store = await openTestAgentSessionRecordStore(directory) + const { location, provider, accountHome } = attachParams() + await foundTestAgentSessionRecord(store, { + sessionId: SESSION, + location, + provider, + accountHome, + claimKeyId: 'key-1', + now: NOW + }) + return store +} + +/** The rows this suite's starts wrote; the create writes one of its own. */ +function startRows(store: Awaited>) { + return store.listOperationRows().filter((row) => row.callerKey === 'client-1') +} + describe('processless structured session reservation', () => { it('refuses an adapter that declares no create support before reserving a lease', async () => { root = await mkdtemp(join(tmpdir(), 'orca-unsupported-attach-')) - const store = await openTestAgentSessionRecordStore(root) + const store = await foundedStore(root) const reserveOwner = vi.spyOn(store, 'reserveOwner') const acquire = vi.fn() const adapter = { @@ -106,7 +127,7 @@ describe('processless structured session reservation', () => { it('refuses a replay when adapter support drifts after durable reservation', async () => { root = await mkdtemp(join(tmpdir(), 'orca-replay-support-drift-')) - const store = await openTestAgentSessionRecordStore(root) + const store = await foundedStore(root) const supportsCreate = vi .fn>() .mockReturnValueOnce(true) @@ -153,7 +174,7 @@ describe('processless structured session reservation', () => { it('releases a new reservation when support drifts before acquisition', async () => { root = await mkdtemp(join(tmpdir(), 'orca-support-drift-reservation-')) - const store = await openTestAgentSessionRecordStore(root) + const store = await foundedStore(root) const supportsCreate = vi .fn>() .mockReturnValueOnce(true) @@ -189,10 +210,10 @@ describe('processless structured session reservation', () => { claimStatus: 'released', handoffStage: null, reservedSpawnToken: null, - runtimeFence: 2, + runtimeFence: 3, deathEvidence: { kind: 'pid-absent', detail: 'reservation failed before spawn' } }) - expect(store.listOperationRows()[0]?.outcome).toMatchObject({ + expect(startRows(store)[0]?.outcome).toMatchObject({ status: 'failed', code: 'structured_agent_session_unsupported' }) @@ -205,7 +226,7 @@ describe('processless structured session reservation', () => { it('settles a pre-spawn failure and its processless evidence in one durable transaction', async () => { root = await mkdtemp(join(tmpdir(), 'orca-processless-reservation-')) - const store = await openTestAgentSessionRecordStore(root) + const store = await foundedStore(root) const adapter = { acquire: vi.fn(async () => { throw new AgentSessionPreSpawnError(new Error('workspace no longer exists')) @@ -237,11 +258,11 @@ describe('processless structured session reservation', () => { claimStatus: 'released', handoffStage: null, handoffOperationId: null, - runtimeFence: 2, + runtimeFence: 3, reservedSpawnToken: null, deathEvidence: { kind: 'pid-absent', detail: 'reservation failed before spawn' } }) - expect(store.listOperationRows()[0]?.outcome).toMatchObject({ status: 'failed' }) + expect(startRows(store)[0]?.outcome).toMatchObject({ status: 'failed' }) const reopened = await openTestAgentSessionRecordStore(root) await reopened.reconcileOnRestart({ @@ -250,14 +271,14 @@ describe('processless structured session reservation', () => { }) expect(reopened.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', - runtimeFence: 2, + runtimeFence: 3, reservedSpawnToken: null }) }) it('does not rerun a settled pre-spawn failure and admits a fresh operation', async () => { root = await mkdtemp(join(tmpdir(), 'orca-processless-retry-')) - const store = await openTestAgentSessionRecordStore(root) + const store = await foundedStore(root) const adapter = { acquire: vi .fn() @@ -313,13 +334,13 @@ describe('processless structured session reservation', () => { spawnToken: 'spawn-b', handoffOperationId: NEXT_OPERATION }, - params: attachParams(NEXT_OPERATION, 2) + params: attachParams(NEXT_OPERATION, 3) }) ).resolves.toMatchObject({ ok: true }) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', handoffStage: null, - runtimeFence: 3, + runtimeFence: 4, ownerProcess: { spawnToken: 'spawn-b' } }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts index 32e6d80cce19..bb126833519f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts @@ -22,7 +22,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { attachForTests } from './structured-agent-session-attach-test-support' +import { attachForTests, startAgentForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } @@ -130,16 +130,20 @@ afterEach(async () => { describe('recovery exits', () => { it('releases an ownerless unproven acquisition, so the next start goes ahead', async () => { acquire.mockRejectedValueOnce(new Error('simulated crash before identity commit')) - await expect(attachForTests(host, CALLER, hostTestAttachParams(null))).rejects.toThrow( - 'agent_session_acquisition_exit_unproven' - ) + expect(await attachForTests(host, CALLER, hostTestAttachParams(null))).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_ownership_unknown', + message: 'agent_session_acquisition_exit_unproven' + } + }) // No owner was recorded, and the adapter closed the stdio of anything it spawned. expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, handoffOperationId: null, ownerProcess: null, - runtimeFence: 2, + runtimeFence: 3, reservedSpawnToken: null, deathEvidence: null }) @@ -164,15 +168,19 @@ describe('recovery exits', () => { mintSpawnToken: () => 'spawn-b', probeOwner: async () => ({ outcome: 'pid-absent' }) }) - await expect(attachForTests(host, CALLER, hostTestAttachParams(2))).rejects.toThrow( - 'agent_session_acquisition_exit_unproven' - ) + expect(await attachForTests(host, CALLER, hostTestAttachParams(2))).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_ownership_unknown', + message: 'agent_session_acquisition_exit_unproven' + } + }) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'reserved', handoffStage: 'recovering', handoffOperationId: null, ownerProcess: { spawnToken: 'spawn-b' }, - runtimeFence: 3, + runtimeFence: 4, reservedSpawnToken: 'spawn-b' }) @@ -193,7 +201,7 @@ describe('recovery exits', () => { handoffOperationId: null, ownerProcess: null, reservedSpawnToken: null, - runtimeFence: 4 + runtimeFence: 5 }) // The ordinary native recovery path remains: the next start resumes it. @@ -202,7 +210,7 @@ describe('recovery exits', () => { expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', handoffStage: null, - runtimeFence: 5, + runtimeFence: 6, ownerProcess: { spawnToken: 'spawn-c' } }) }) @@ -224,18 +232,14 @@ describe('recovery exits', () => { stopOwnerProcess }) - const stale = await attachForTests(host, CALLER, hostTestAttachParams(1)) - expect(stale).toMatchObject({ - ok: false, - refusal: { code: 'agent_session_checkpoint_stale', currentFence: 2 } - }) - expect(stopOwnerProcess).toHaveBeenCalledWith(4242, 'SIGTERM') - const retried = await attachForTests(host, CALLER, hostTestAttachParams(2)) + // The start reconciles the lease first: the orphan is stopped, then a fresh child is spawned. + const retried = await startAgentForTests(host, SESSION) expect(retried).toMatchObject({ ok: true }) + expect(stopOwnerProcess).toHaveBeenCalledWith(4242, 'SIGTERM') // A fresh child was spawned; the orphan pid's lease did not survive as the owner. expect(acquire).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease).toMatchObject({ - runtimeFence: 3, + runtimeFence: 4, claimStatus: 'live', handoffStage: null }) @@ -274,7 +278,7 @@ describe('recovery exits', () => { expect(acquire).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease).toMatchObject({ - runtimeFence: 3, + runtimeFence: 4, claimStatus: 'live', handoffStage: null, ownerProcess: { spawnToken: 'spawn-b' } @@ -289,7 +293,7 @@ describe('recovery exits', () => { linkId: 'link-outgoing', handle: { provider: 'codex', threadId: THREAD }, origin: 'created', - mintedAtFence: 1, + mintedAtFence: 2, observedAt: NOW } }) @@ -309,7 +313,7 @@ describe('recovery exits', () => { overlapDriven = true await outgoingStore.renewLease({ sessionId: SESSION, - fence: 1, + fence: 2, childProbe: probe, now: NOW + 1 }) @@ -321,7 +325,7 @@ describe('recovery exits', () => { await host.restoreReadableSessions() expect(store.getRecord(SESSION)?.lease).toMatchObject({ - runtimeFence: 2, + runtimeFence: 3, claimStatus: 'released', ownerProcess: null }) @@ -334,7 +338,7 @@ describe('recovery exits', () => { linkId: 'link-replacement', handle: { provider: 'codex', threadId: THREAD }, origin: 'resumed', - mintedAtFence: 3, + mintedAtFence: 4, observedAt: NOW + 2 } }) @@ -342,7 +346,7 @@ describe('recovery exits', () => { await startAgent() expect(store.getRecord(SESSION)?.lease).toMatchObject({ - runtimeFence: 3, + runtimeFence: 4, claimStatus: 'live', handoffStage: null, ownerProcess: { pid: replacement.process.pid, spawnToken: 'spawn-b' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts index 8e3be9d1f3d7..66a2c367db64 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts @@ -9,7 +9,10 @@ import { writeOlderBuildLease } from '../../runtime/agent-session-older-build-lease.test-fixture' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { + foundAndReserveTestAgentSessionRecord, + openTestAgentSessionRecordStore +} from '../../runtime/agent-session-record-store-test-harness' import { supervisedPosixLaunch } from '../../codex/codex-app-server-posix-supervisor' import { resolveStructuredSessionRecovery, @@ -38,7 +41,7 @@ async function openStore(directory?: string): Promise { async function reserve(store: AgentSessionRecordStore) { operations += 1 - return store.reserveOwner({ + return foundAndReserveTestAgentSessionRecord(store, { sessionId: SESSION, location: { executionHostId: 'local', @@ -48,7 +51,6 @@ async function reserve(store: AgentSessionRecordStore) { }, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex' }, - expectedFence: null, spawnToken: 'spawn-recovery', claimKeyId: 'key-1', handoffOperationId: null, @@ -131,7 +133,7 @@ describe('structured session recovery resolution', () => { expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, - runtimeFence: 2, + runtimeFence: 3, reservedSpawnToken: null, deathEvidence: null }) @@ -153,7 +155,7 @@ describe('structured session recovery resolution', () => { expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, - runtimeFence: 2, + runtimeFence: 3, ownerProcess: null }) }) @@ -185,7 +187,7 @@ describe('structured session recovery resolution', () => { expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, - runtimeFence: 2 + runtimeFence: 3 }) }) @@ -211,7 +213,7 @@ describe('structured session recovery resolution', () => { expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, - runtimeFence: 2, + runtimeFence: 3, ownerProcess: null, deathEvidence: null }) @@ -236,7 +238,7 @@ describe('structured session recovery resolution', () => { expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, - runtimeFence: 2, + runtimeFence: 3, ownerProcess: null, deathEvidence: null }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts index d3708d3d90af..7e741a686a5a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts @@ -51,7 +51,9 @@ beforeEach(async () => { link: { linkId: `link-${fence}`, handle: { provider: 'codex' as const, threadId: THREAD }, - origin: fence > 1 ? ('resumed' as const) : ('created' as const), + origin: store.getRecord(SESSION)?.providerHandleChain.length + ? ('resumed' as const) + : ('created' as const), mintedAtFence: fence, observedAt: NOW } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index c65f95da083c..b478d3a2f7b9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -29,7 +29,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { attachForTests } from './structured-agent-session-attach-test-support' +import { attachForTests, startAgentForTests } from './structured-agent-session-attach-test-support' const caller = { callerKey: 'desktop' } let directory: string @@ -106,6 +106,12 @@ afterEach(async () => { await rm(directory, { recursive: true, force: true }) }) +/** An interrupted rewind recovers when the agent next starts: here, after the host puts it to rest. */ +async function restartAgent() { + await host.collaboratorsForTests().lifetime.stopAgent(HOST_TEST_SESSION, 'evict') + return startAgentForTests(host, HOST_TEST_SESSION) +} + async function seed(acceptedSubmissions = false) { expect(await attachForTests(host, caller, hostTestAttachParams(null))).toMatchObject({ ok: true }) const keys = ['kept', 'drop', 'tip'].map((turnId) => ({ @@ -195,8 +201,7 @@ describe('host rewind', () => { await expect(host.rewind(caller, request)).rejects.toThrow('disk failed') expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('provider-succeeded') replace.mockRestore() - const fence = store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence - expect(await attachForTests(host, caller, hostTestAttachParams(fence))).toMatchObject({ + expect(await restartAgent()).toMatchObject({ ok: true }) expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(1) @@ -215,21 +220,12 @@ describe('host rewind', () => { expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) expect(store.getRecord(HOST_TEST_SESSION)?.rewind).toMatchObject({ phase: 'prepared' }) recoverRewind.mockRejectedValueOnce(new Error('history still unavailable')) - await expect( - attachForTests( - host, - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) - ).rejects.toThrow('history still unavailable') + expect(await restartAgent()).toMatchObject({ + ok: false, + refusal: { message: expect.stringContaining('history still unavailable') } + }) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('prepared') - expect( - await attachForTests( - host, - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) - ).toMatchObject({ ok: true }) + expect(await restartAgent()).toMatchObject({ ok: true }) expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(1) expect((await host.journalSnapshot(HOST_TEST_SESSION)).items[0]?.body).toEqual( hostTestMessage('verified history') @@ -316,13 +312,7 @@ describe('host rewind', () => { refusal: { rewindReason: 'outcome-unknown' } }) expect(adapter.dispatch).not.toHaveBeenCalled() - expect( - await attachForTests( - host, - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) - ).toMatchObject({ ok: true }) + expect(await restartAgent()).toMatchObject({ ok: true }) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true }) expect(rewind).toHaveBeenCalledTimes(1) @@ -334,13 +324,7 @@ describe('host rewind', () => { rewind.mockRejectedValueOnce(new Error('read failed before revert')) await expect(host.rewind(caller, await params(target))).rejects.toThrow('read failed') recoverRewind.mockResolvedValueOnce({ ok: false, reason: 'provider-refused' }) - expect( - await attachForTests( - host, - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) - ).toMatchObject({ ok: true }) + expect(await restartAgent()).toMatchObject({ ok: true }) expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('refused') expect(await host.rewind(caller, await params(target))).toMatchObject({ ok: true }) @@ -356,13 +340,7 @@ describe('host rewind', () => { await expect(host.rewind(caller, await params(target))).rejects.toThrow('history unavailable') expect(store.getRecord(HOST_TEST_SESSION)?.rewind).toMatchObject({ phase: 'prepared' }) recoverRewind.mockResolvedValueOnce({ ok: false, reason: 'provider-refused' }) - expect( - await attachForTests( - host, - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) - ).toMatchObject({ ok: true }) + expect(await restartAgent()).toMatchObject({ ok: true }) expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('refused') const body = hostTestMessage('after the refused rewind') @@ -606,13 +584,7 @@ describe('host rewind', () => { ok: true, items: [{ identity: message('kept'), body: hostTestMessage('kept from recovery') }] }) - expect( - await attachForTests( - host, - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) - ).toMatchObject({ ok: true }) + expect(await restartAgent()).toMatchObject({ ok: true }) expect( (await host.journalSnapshot(HOST_TEST_SESSION)).items.map(({ itemId, body }) => ({ @@ -638,13 +610,7 @@ describe('host rewind', () => { }) await expect(host.rewind(caller, await params(target))).rejects.toThrow('lost after revert') recoverRewind.mockResolvedValueOnce({ ok: true, items }) - expect( - await attachForTests( - host, - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) - ).toMatchObject({ ok: true }) + expect(await restartAgent()).toMatchObject({ ok: true }) expect((await host.journalSnapshot(HOST_TEST_SESSION)).items).toHaveLength(2) expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed') }) @@ -673,13 +639,10 @@ describe('host rewind', () => { items: missing === 'turn' ? [] : items.slice(0, 1) }) const replace = vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems') - await expect( - attachForTests( - host, - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) - ).rejects.toThrow('proof-mismatch') + expect(await restartAgent()).toMatchObject({ + ok: false, + refusal: { message: expect.stringContaining('proof-mismatch') } + }) expect(replace).not.toHaveBeenCalled() replace.mockRestore() expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.expectedEpoch).toBe(before.cursor.epoch) @@ -707,13 +670,7 @@ describe('host rewind', () => { expect(committed.cursor.epoch).not.toBe(request.expectedEpoch) checkpoint.mockRestore() const replace = vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems') - expect( - await attachForTests( - host, - caller, - hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence) - ) - ).toMatchObject({ ok: true }) + expect(await restartAgent()).toMatchObject({ ok: true }) expect(await host.journalSnapshot(HOST_TEST_SESSION)).toEqual(committed) expect(replace).not.toHaveBeenCalled() replace.mockRestore() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts index 11892ffcb800..f06d2b959b13 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -1,6 +1,6 @@ -// What a client's retry sees around a failed attach settlement: a crash between -// reserve and settlement replays into the original reservation, and a settled -// failure refuses sends without ever re-dispatching on the user's behalf. +// What the next start sees around a failed start's settlement: a crash between reserve and +// settlement is released at restart, a replay of the same operation continues its reservation, and +// a settled failure refuses sends without ever re-dispatching on the user's behalf. import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -26,7 +26,11 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { attachForTests } from './structured-agent-session-attach-test-support' +import { + attachExistingForTests, + attachForTests, + startAgentForTests +} from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } @@ -132,7 +136,6 @@ describe('settled attach retry', () => { it('settles a post-acquisition journal failure and retries without a restart', async () => { const historyFilePath = vi .fn>() - .mockRejectedValueOnce(new Error('journal path unavailable')) .mockResolvedValue(null) host = new StructuredAgentSessionHost({ store, @@ -142,30 +145,34 @@ describe('settled attach retry', () => { mintSpawnToken: () => 'spawn-a', now: () => NOW }) - const first = hostTestAttachParams(null) + expect(await host.create(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + // Closed, so the start opens the journal again after it acquires. + await host.close(SESSION, 'evict') + historyFilePath.mockRejectedValueOnce(new Error('journal path unavailable')) - await expect(attachForTests(host, CALLER, first)).rejects.toThrow('journal path unavailable') + expect(await startAgentForTests(host, SESSION)).toMatchObject({ + ok: false, + refusal: { message: 'journal path unavailable' } + }) expect(releaseAcquisition).toHaveBeenCalledOnce() expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, ownerProcess: null, reservedSpawnToken: null, - runtimeFence: 2 + runtimeFence: 3 }) expect( - store.listOperationRows().find((row) => row.operationId === first.envelope.clientOperationId) + store.listOperationRows().find((row) => row.callerKey === 'trusted-local:agent-start') ?.outcome ).toMatchObject({ status: 'failed' }) - await expect(attachForTests(host, CALLER, hostTestAttachParams(2))).resolves.toMatchObject({ - ok: true - }) + await expect(startAgentForTests(host, SESSION)).resolves.toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledTimes(2) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', handoffStage: null, - runtimeFence: 3 + runtimeFence: 4 }) }) @@ -201,13 +208,17 @@ describe('settled attach retry', () => { mintSpawnToken, now: () => NOW }) - const params = hostTestAttachParams(null) - publishFault.failOnPublish = 2 + expect(await host.create(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + const params = hostTestAttachParams(1) + publishFault.failOnPublish = publishFault.publishCount + 2 - await expect(attachForTests(host, CALLER, params)).rejects.toThrow( + await expect(attachExistingForTests(host, CALLER, params)).rejects.toThrow( 'agent session acquisition failure settlement failed' ) - expect(await attachForTests(host, CALLER, params)).toMatchObject({ ok: true, replayed: true }) + expect(await attachExistingForTests(host, CALLER, params)).toMatchObject({ + ok: true, + replayed: true + }) expect(mintSpawnToken).toHaveBeenCalledOnce() expect(spawnTokens).toEqual(['spawn-safe', 'spawn-safe']) }) @@ -250,17 +261,16 @@ describe('settled attach retry', () => { }), now: () => NOW }) - const params = hostTestAttachParams(null) - publishFault.failOnPublish = 2 + expect(await host.create(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) + publishFault.failOnPublish = publishFault.publishCount + 2 - await expect(attachForTests(host, CALLER, params)).rejects.toThrow( + await expect(startAgentForTests(host, SESSION)).rejects.toThrow( 'agent session acquisition failure settlement failed' ) - expect(publishFault.publishCount).toBe(2) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'reserved', handoffStage: 'new-owner-proving', - runtimeFence: 1, + runtimeFence: 2, reservedSpawnToken: 'spawn-1', ownerProcess: null }) @@ -287,20 +297,20 @@ describe('settled attach retry', () => { expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, - runtimeFence: 2, + runtimeFence: 3, reservedSpawnToken: null, ownerProcess: null, deathEvidence: null }) - // The interrupted operation's own retry continues it as a fresh reservation. - await expect(attachForTests(host, CALLER, params)).resolves.toMatchObject({ ok: true }) + // The next start, the chat's next message's, goes ahead as a fresh reservation. + await expect(startAgentForTests(host, SESSION)).resolves.toMatchObject({ ok: true }) expect(mintSpawnToken).toHaveBeenCalledTimes(2) expect(spawnTokens).toEqual(['spawn-1', 'spawn-2']) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', runtimeKind: 'native', - runtimeFence: 3, + runtimeFence: 4, handoffStage: null, handoffOperationId: null, ownerProcess: { spawnToken: 'spawn-2' } @@ -338,7 +348,7 @@ describe('settled attach retry', () => { expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', handoffStage: null, - runtimeFence: 3 + runtimeFence: 4 }) expect(dispatch).toHaveBeenCalledTimes(1) @@ -384,7 +394,7 @@ describe('settled attach retry', () => { await expect(attachForTests(host, CALLER, hostTestAttachParams(null))).resolves.toMatchObject({ ok: false, refusal: { - message: "Codex couldn't restart. Send your message to try again.", + message: "Codex couldn't start. Send your message to try again.", ownerVerdict: 'exited' } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 5ab6eb39b5e7..25c3736991e8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -563,7 +563,7 @@ describe('already-wedged profiles become usable on load', () => { ).toBe(false) }) - it("leaves the live generation's running turn alone on a re-attach", async () => { + it("leaves the live generation's running turn alone on a second start", async () => { await seedStore(wedgedRecord({ claimStatus: 'released', handoffStage: null })) // The child this host spawns stays provably alive across the second attach. openHost({ @@ -578,8 +578,8 @@ describe('already-wedged profiles become usable on load', () => { { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) - // A reconnecting client replays its attach; the same operation admits the live owner. - expect(await attachForTests(host, CALLER, params)).toMatchObject({ ok: true, replayed: true }) + // The next start, a second message's, finds the agent running and leaves it alone. + expect(await attachForTests(host, CALLER, params)).toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledOnce() expect(turnLifecycle('turn-2')).toEqual({ turnId: 'turn-2', state: 'running', startedAt: NOW }) diff --git a/src/main/runtime/agent-session-at-rest-create.test.ts b/src/main/runtime/agent-session-at-rest-create.test.ts new file mode 100644 index 000000000000..e6403e349e52 --- /dev/null +++ b/src/main/runtime/agent-session-at-rest-create.test.ts @@ -0,0 +1,247 @@ +// A chat's create founds its record at rest, and is the only thing that does: adoption, the tab +// it reserves, and every refusal of a record that cannot be founded are decided here. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import type { + AgentSessionExecutionLocation, + AgentSessionRecord +} from '../../shared/agent-session-record' +import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { + commitAgentSessionAtRestCreate, + type AgentSessionAtRestCreateRequest +} from './agent-session-at-rest-create' +import type { AgentSessionStoreState } from './agent-session-record-store-file' +import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' + +const NOW = 1_800_000_000_000 +const SESSION = 'session-creating' +const LOCATION: AgentSessionExecutionLocation = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' +} +const ACCOUNT_HOME = { variable: 'CLAUDE_CONFIG_DIR' as const, path: '/home/dev/.claude' } + +let operations = 0 +function operationId(): string { + operations += 1 + return `${NOW}-${operations.toString(16).padStart(32, '0')}` +} + +function createRequest( + overrides: Partial = {} +): AgentSessionAtRestCreateRequest { + return { + sessionId: SESSION, + location: LOCATION, + provider: 'claude', + accountHome: ACCOUNT_HOME, + claimKeyId: 'key-1', + operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-create' }, + now: NOW, + ...overrides + } +} + +function storeState(records: readonly AgentSessionRecord[] = []): AgentSessionStoreState { + return { + schemaVersion: 2, + hostId: 'local', + records: new Map(records.map((record) => [record.sessionId, record])), + operations: new Map(), + retiredClaimKeys: [], + unreadableRecords: new Map(), + sessionTabs: null + } +} + +function adoptedLink( + overrides: Partial = {} +): AgentSessionProviderHandleLink { + return { + linkId: 'claude-1-provider-session-alpha-1-empty', + handle: { provider: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: null }, + origin: 'adopted', + mintedAtFence: 1, + observedAt: NOW, + ...overrides + } +} + +/** Another chat whose agent already ran the conversation `adoptedLink` names, on another leaf. */ +function holderOfAdoptedConversation(): AgentSessionRecord { + return { + ...agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId: 'session-holder' })), + providerHandleChain: [ + adoptedLink({ + linkId: 'claude-1-provider-session-alpha-1-leaf', + handle: { provider: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: 'leaf-1' }, + origin: 'created' + }) + ] + } +} + +describe('a create at rest', () => { + it('founds a released record at fence 1 with an empty chain, so its first start is fresh', () => { + const state = storeState() + const { record, replayed } = commitAgentSessionAtRestCreate(state, createRequest()) + + expect(replayed).toBe(false) + expect(record.providerHandleChain).toEqual([]) + expect(record.lease).toMatchObject({ + runtimeFence: 1, + claimStatus: 'released', + ownerProcess: null, + deathEvidence: null + }) + expect(state.records.get(SESSION)).toEqual(record) + }) + + it('replays the record its own operation founded, and writes nothing more', () => { + const state = storeState() + const request = createRequest() + const first = commitAgentSessionAtRestCreate(state, request) + + const replay = commitAgentSessionAtRestCreate(state, { ...request, now: NOW + 1 }) + + expect(replay).toMatchObject({ replayed: true, record: first.record }) + expect(state.operations.size).toBe(1) + }) + + it('refuses a second create of a session that exists', () => { + const state = storeState() + commitAgentSessionAtRestCreate(state, createRequest()) + + expect(() => commitAgentSessionAtRestCreate(state, createRequest())).toThrow( + expect.objectContaining({ + refusal: expect.objectContaining({ + code: 'agent_session_conflict', + details: { reason: 'sessionExists' } + }) + }) + ) + }) + + it('refuses a session whose record this build cannot read, as reconciling', () => { + const state = storeState() + state.unreadableRecords.set(SESSION, { reason: 'invalid', raw: {} }) + + expect(() => commitAgentSessionAtRestCreate(state, createRequest())).toThrow( + expect.objectContaining({ + refusal: expect.objectContaining({ + code: 'execution_owner_reconciling', + details: { reason: 'recordUnreadable' } + }) + }) + ) + expect(state.records.size).toBe(0) + }) + + it('refuses launch args or options it cannot store, before founding anything', () => { + const state = storeState() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a malformed payload the store must refuse. + const launchArgs = [42] as unknown as string[] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a malformed payload the store must refuse. + const options = { model: 7 } as unknown as Record + + expect(() => commitAgentSessionAtRestCreate(state, createRequest({ launchArgs }))).toThrow( + 'agent_session_launch_args_invalid' + ) + expect(() => commitAgentSessionAtRestCreate(state, createRequest({ options }))).toThrow( + 'agent_session_options_invalid' + ) + expect(state.records.size).toBe(0) + }) +}) + +describe('an adopting create at rest', () => { + it('seeds the chain with the adopted link alone, at the record fence its first start moves', () => { + const link = adoptedLink() + const { record } = commitAgentSessionAtRestCreate( + storeState(), + createRequest({ adoptedHandleLink: link }) + ) + + expect(record.providerHandleChain).toEqual([link]) + expect(record.lease.runtimeFence).toBe(link.mintedAtFence) + }) + + it('refuses a conversation another record already holds, by its root', () => { + // The held link names a leaf; the adoption names none. Keying on the exact handle would let two + // writers onto one conversation on different branches. + const state = storeState([holderOfAdoptedConversation()]) + + expect(() => + commitAgentSessionAtRestCreate(state, createRequest({ adoptedHandleLink: adoptedLink() })) + ).toThrow( + expect.objectContaining({ + refusal: expect.objectContaining({ + code: 'agent_session_conflict', + details: { reason: 'conversationHeldElsewhere' } + }) + }) + ) + expect(state.records.has(SESSION)).toBe(false) + }) + + it('admits a conversation no record holds', () => { + const state = storeState([holderOfAdoptedConversation()]) + const other = adoptedLink({ + handle: { provider: 'claude', sessionId: 'provider-session-other', leafUuid: null } + }) + + expect( + commitAgentSessionAtRestCreate(state, createRequest({ adoptedHandleLink: other })).record + .providerHandleChain + ).toEqual([other]) + }) +}) + +describe('the tab a create reserves', () => { + let directory: string + + beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), 'orca-at-rest-create-tab-')) + }) + afterEach(async () => { + await rm(directory, { recursive: true, force: true }) + }) + + it('takes no tab at create, then answers a replay naming another tab with the one it was given', async () => { + const store = await openTestAgentSessionRecordStore(directory) + const request = createRequest({ surfaceTabId: 'chat-tab-1' }) + await store.createAtRest(request) + // Publishing the tab takes the id, so a create that never gets there leaves nothing behind. + expect(store.getSessionTabId(SESSION)).toBeNull() + + await store.setSessionTabVisibility(SESSION, true, 'chat-tab-1') + expect(await store.createAtRest({ ...request, surfaceTabId: 'chat-tab-2' })).toMatchObject({ + replayed: true + }) + expect(store.getSessionTabId(SESSION)).toBe('chat-tab-1') + }) + + it("refuses a tab id another session's tab holds, and a malformed one", async () => { + const store = await openTestAgentSessionRecordStore(directory) + await store.createAtRest(createRequest({ surfaceTabId: 'chat-tab-1' })) + await store.setSessionTabVisibility(SESSION, true, 'chat-tab-1') + + await expect( + store.createAtRest(createRequest({ sessionId: 'session-other', surfaceTabId: 'chat-tab-1' })) + ).rejects.toMatchObject({ refusal: { code: 'agent_session_conflict' } }) + await expect( + store.createAtRest(createRequest({ sessionId: 'session-other', surfaceTabId: '' })) + ).rejects.toMatchObject({ refusal: { code: 'agent_session_operation_invalid' } }) + expect(store.getRecord('session-other')).toBeNull() + }) +}) diff --git a/src/main/runtime/agent-session-at-rest-create.ts b/src/main/runtime/agent-session-at-rest-create.ts index 3ea39be9304f..8486deaaf17d 100644 --- a/src/main/runtime/agent-session-at-rest-create.ts +++ b/src/main/runtime/agent-session-at-rest-create.ts @@ -9,30 +9,29 @@ import { } from '../../shared/agent-session-operation-ledger' import { isAgentSessionOptions, type AgentSessionRecord } from '../../shared/agent-session-record' import { isAgentSessionLaunchArgs } from '../../shared/agent-session-launch-args' +import { isAgentSessionSurfaceTabId } from '../../shared/agent-session-surface-tab-id' +import { + agentSessionProviderHandleRoot, + type AgentSessionProviderHandleLink +} from '../../shared/agent-session-provider-handle' import type { AgentSessionStoreState } from './agent-session-record-store-file' -import { foundAgentSessionRecord } from './agent-session-record-founding' import { - assertAdoptedConversationUnowned, - assertReservedTabUnheld, + foundAgentSessionRecord, + type AgentSessionRecordIdentity +} from './agent-session-record-founding' +import { evaluateAgentSessionReserveOperation, requireAgentSessionRecordForReplay, type AgentSessionReserveRequest } from './agent-session-reservation-admission' -export type AgentSessionAtRestCreateRequest = Pick< - AgentSessionReserveRequest, - | 'sessionId' - | 'location' - | 'provider' - | 'accountHome' - | 'launchArgs' - | 'options' - | 'surfaceTabId' - | 'adoptedHandleLink' - | 'claimKeyId' - | 'operation' - | 'now' -> +export type AgentSessionAtRestCreateRequest = AgentSessionRecordIdentity & + Pick & { + /** The tab id a create reserves for this conversation, taken when its tab is published. */ + surfaceTabId?: string + /** The provider conversation this create adopts; its first start resumes it. */ + adoptedHandleLink?: AgentSessionProviderHandleLink + } export type AgentSessionAtRestCreateResult = { record: AgentSessionRecord @@ -82,3 +81,58 @@ export function commitAgentSessionAtRestCreate( ) return { record, operationRow, replayed: false } } + +/** + * Refuse an adoption whose conversation ANOTHER record already holds. + * + * It runs inside the store transaction because the pre-commit check in the RPC resolver cannot be + * the guard: two concurrent adoptions of one conversation mint different session ids, so neither + * sees the other's record and both would pass. Codex permits two app-servers on one thread + * silently, so the cost of missing this is a corrupted conversation rather than an error. + */ +function assertAdoptedConversationUnowned( + state: AgentSessionStoreState, + request: Pick +): void { + const adopted = request.adoptedHandleLink + if (!adopted) { + return + } + const root = agentSessionProviderHandleRoot(adopted.handle) + for (const record of state.records.values()) { + if (record.sessionId === request.sessionId) { + continue + } + const holdsSameConversation = record.providerHandleChain.some( + (link) => agentSessionProviderHandleRoot(link.handle) === root + ) + if (holdsSameConversation) { + throw agentSessionRefusalError('agent_session_conflict', { + reason: 'conversationHeldElsewhere' + }) + } + } +} + +/** + * A tab id names one conversation, so a reserved id another session's tab holds is a conflict. + * Checked, not claimed: the id is taken when the chat's tab is published, so a create that never + * gets that far leaves nothing in the table to restore or release. + */ +function assertReservedTabUnheld( + state: AgentSessionStoreState, + request: Pick +): void { + if (request.surfaceTabId === undefined) { + return + } + if (!isAgentSessionSurfaceTabId(request.surfaceTabId)) { + throw agentSessionRefusalError('agent_session_operation_invalid', { + reason: 'requestMalformed' + }) + } + const holder = state.sessionTabs?.sessionIdFor(request.surfaceTabId) + if (holder !== undefined && holder !== request.sessionId) { + throw agentSessionRefusalError('agent_session_conflict', { reason: 'tabIdTaken' }) + } +} diff --git a/src/main/runtime/agent-session-conversation-clear-commit.test.ts b/src/main/runtime/agent-session-conversation-clear-commit.test.ts index 3e7c63a41c34..982e03cc0b4a 100644 --- a/src/main/runtime/agent-session-conversation-clear-commit.test.ts +++ b/src/main/runtime/agent-session-conversation-clear-commit.test.ts @@ -8,7 +8,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import { isAgentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import type { AgentSessionRecordStore } from './agent-session-record-store' -import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' +import { + foundAndReserveTestAgentSessionRecord, + openTestAgentSessionRecordStore +} from './agent-session-record-store-test-harness' const NOW = 1_800_000_000_000 const SOURCE = 'session-alpha' @@ -29,7 +32,7 @@ const open = () => openTestAgentSessionRecordStore(directory) async function storeWithSource(): Promise { const store = await open() - await store.reserveOwner({ + await foundAndReserveTestAgentSessionRecord(store, { sessionId: SOURCE, location: { executionHostId: 'local', @@ -41,7 +44,6 @@ async function storeWithSource(): Promise { accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, launchArgs: ['--flag'], options: { model: 'sonnet' }, - expectedFence: null, spawnToken: 'spawn-a', claimKeyId: 'key-1', handoffOperationId: null, diff --git a/src/main/runtime/agent-session-conversation-name-store.test.ts b/src/main/runtime/agent-session-conversation-name-store.test.ts index 07c1345f8da4..b50424ccd9b0 100644 --- a/src/main/runtime/agent-session-conversation-name-store.test.ts +++ b/src/main/runtime/agent-session-conversation-name-store.test.ts @@ -5,7 +5,10 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import type { AgentSessionRecordStore } from './agent-session-record-store' -import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' +import { + foundAndReserveTestAgentSessionRecord, + openTestAgentSessionRecordStore +} from './agent-session-record-store-test-harness' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' const NOW = 1_800_000_000_000 @@ -26,12 +29,11 @@ function operationId(): string { .replaceAll(/[^0-9a-f]/g, '0')}` } -const reserveRequest = (): AgentSessionReserveRequest => ({ +const reserveRequest = (): Omit => ({ sessionId: SESSION, location: NATIVE, provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, - expectedFence: null, spawnToken: 'spawn-a', claimKeyId: 'key-1', handoffOperationId: null, @@ -51,7 +53,7 @@ afterEach(async () => { async function reservedStore(): Promise { const store = await openTestAgentSessionRecordStore(directory) - await store.reserveOwner(reserveRequest()) + await foundAndReserveTestAgentSessionRecord(store, reserveRequest()) return store } diff --git a/src/main/runtime/agent-session-death-evidence-persistence.test.ts b/src/main/runtime/agent-session-death-evidence-persistence.test.ts index 9904ca7994f4..94458ae91efe 100644 --- a/src/main/runtime/agent-session-death-evidence-persistence.test.ts +++ b/src/main/runtime/agent-session-death-evidence-persistence.test.ts @@ -10,6 +10,7 @@ import { import type { AgentSessionFailedAcquisitionSettlement } from './agent-session-acquisition-failure-settlement' import type { AgentSessionRecordStore } from './agent-session-record-store' import { + foundAndReserveTestAgentSessionRecord, openTestAgentSessionRecordStore, seedTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' @@ -113,12 +114,11 @@ describe('a failed acquisition', () => { /** Reserve and observe the spawn at NOW, as an attach does before it can fail. */ async function spawnedOwner(store: AgentSessionRecordStore): Promise { - const reserved = await store.reserveOwner({ + const reserved = await foundAndReserveTestAgentSessionRecord(store, { sessionId: SESSION, location: agentSessionRecordFixture().location, provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, - expectedFence: null, spawnToken: 'spawn-a', claimKeyId: 'key-1', handoffOperationId: OPERATION_ID, diff --git a/src/main/runtime/agent-session-launch-env-admission.test.ts b/src/main/runtime/agent-session-launch-env-admission.test.ts index 6cb9a576078f..6bf0416fefda 100644 --- a/src/main/runtime/agent-session-launch-env-admission.test.ts +++ b/src/main/runtime/agent-session-launch-env-admission.test.ts @@ -2,7 +2,9 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentSessionRecordStore } from './agent-session-record-store' import { + foundTestAgentSessionRecord, openTestAgentSessionRecordStore, readPersistedTestAgentSessionStoreText } from './agent-session-record-store-test-harness' @@ -23,7 +25,7 @@ function request(overrides: Partial = {}): AgentSess }, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, - expectedFence: null, + expectedFence: 1, spawnToken: 'spawn-a', claimKeyId: 'key-1', handoffOperationId: null, @@ -38,6 +40,21 @@ function request(overrides: Partial = {}): AgentSess } } +/** The record a create founds at rest, which every start then reserves. */ +async function foundedStore(): Promise { + const store = await openTestAgentSessionRecordStore(directory) + const { sessionId, location, provider, accountHome, claimKeyId } = request() + await foundTestAgentSessionRecord(store, { + sessionId, + location, + provider, + accountHome, + claimKeyId, + now: NOW + }) + return store +} + beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-launch-env-')) }) @@ -48,11 +65,11 @@ afterEach(async () => { describe('agent session launch environment admission', () => { it('does not persist ambient launch variables', async () => { - const store = await openTestAgentSessionRecordStore(directory) + const store = await foundedStore() await store.reserveOwner(request()) await store.reserveOwner( request({ - expectedFence: 1, + expectedFence: 2, spawnToken: 'spawn-b', launchEnv: { PATH: '/custom/bin:/usr/bin', @@ -74,7 +91,8 @@ describe('agent session launch environment admission', () => { }) it('rejects an environment that could not be validated before writing', async () => { - const store = await openTestAgentSessionRecordStore(directory) + const store = await foundedStore() + const founded = store.getRecord(SESSION) const launchEnv = Object.fromEntries( Array.from({ length: 257 }, (_, index) => [`KEY_${index}`, 'value']) ) @@ -82,15 +100,16 @@ describe('agent session launch environment admission', () => { await expect(store.reserveOwner(request({ launchEnv }))).rejects.toThrow( 'agent_session_launch_env_invalid' ) - expect(store.getRecord(SESSION)).toBeNull() + expect(store.getRecord(SESSION)).toEqual(founded) }) it('rejects an overlong environment key before writing it', async () => { - const store = await openTestAgentSessionRecordStore(directory) + const store = await foundedStore() + const founded = store.getRecord(SESSION) await expect( store.reserveOwner(request({ launchEnv: { ['K'.repeat(513)]: 'value' } })) ).rejects.toThrow('agent_session_launch_env_invalid') - expect(store.getRecord(SESSION)).toBeNull() + expect(store.getRecord(SESSION)).toEqual(founded) }) }) diff --git a/src/main/runtime/agent-session-lease-renewal.test.ts b/src/main/runtime/agent-session-lease-renewal.test.ts index ec636a96eed5..75df25979b27 100644 --- a/src/main/runtime/agent-session-lease-renewal.test.ts +++ b/src/main/runtime/agent-session-lease-renewal.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { AgentSessionRecordStore } from './agent-session-record-store' import { + foundAndReserveTestAgentSessionRecord, openTestAgentSessionRecordStore, readPersistedTestAgentSessionStoreText } from './agent-session-record-store-test-harness' @@ -20,7 +21,7 @@ async function establishOwner( ): Promise { const sessionId = `session-${suffix}` const spawnToken = `spawn-${suffix}` - const reserved = await store.reserveOwner({ + const reserved = await foundAndReserveTestAgentSessionRecord(store, { sessionId, location: { executionHostId: 'local', @@ -30,7 +31,6 @@ async function establishOwner( }, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: directory }, - expectedFence: null, spawnToken, claimKeyId: 'key-1', handoffOperationId: null, @@ -92,7 +92,7 @@ describe('agent-session lease renewal batch', () => { const { directory, store } = await liveStore() await store.evictProvenDeadOwner({ sessionId: 'session-b', - expectedFence: 1, + expectedFence: 2, probe: { outcome: 'pid-absent' }, now: NOW + 5_000 }) @@ -101,8 +101,8 @@ describe('agent-session lease renewal batch', () => { await expect( store.renewLeases([ - { sessionId: 'session-a', fence: 1, childProbe: MATCHED, now: NOW + 10_000 }, - { sessionId: 'session-b', fence: 1, childProbe: MATCHED, now: NOW + 10_000 } + { sessionId: 'session-a', fence: 2, childProbe: MATCHED, now: NOW + 10_000 }, + { sessionId: 'session-b', fence: 2, childProbe: MATCHED, now: NOW + 10_000 } ]) ).rejects.toThrow('agent_session_checkpoint_stale') diff --git a/src/main/runtime/agent-session-record-options.test.ts b/src/main/runtime/agent-session-record-options.test.ts index 58385e292726..6c2661ac6a21 100644 --- a/src/main/runtime/agent-session-record-options.test.ts +++ b/src/main/runtime/agent-session-record-options.test.ts @@ -3,7 +3,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, expect, it } from 'vitest' import { readNativeSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' -import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' +import { + foundAndReserveTestAgentSessionRecord, + openTestAgentSessionRecordStore +} from './agent-session-record-store-test-harness' const NOW = 1_800_000_000_000 const SESSION = 'session-options' @@ -47,7 +50,7 @@ it('drops provider-rejected persisted options before the next owner proof', asyn it('persists resumed provider options atomically with owner proof', async () => { const store = await openTestAgentSessionRecordStore(directory) - const reserved = await store.reserveOwner({ + const reserved = await foundAndReserveTestAgentSessionRecord(store, { sessionId: SESSION, location: { executionHostId: 'local', @@ -57,7 +60,6 @@ it('persists resumed provider options atomically with owner proof', async () => }, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/accounts/codex' }, - expectedFence: null, spawnToken: 'spawn-options', claimKeyId: 'key-1', handoffOperationId: null, diff --git a/src/main/runtime/agent-session-record-store-tab-table.test.ts b/src/main/runtime/agent-session-record-store-tab-table.test.ts index 40ee8a36b86c..68ac2a55de51 100644 --- a/src/main/runtime/agent-session-record-store-tab-table.test.ts +++ b/src/main/runtime/agent-session-record-store-tab-table.test.ts @@ -7,15 +7,15 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import { isAgentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import type { AgentSessionRecordStore } from './agent-session-record-store' import { + foundTestAgentSessionRecord, openTestAgentSessionRecordStore, readPersistedTestAgentSessionStore } from './agent-session-record-store-test-harness' -import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' +import type { AgentSessionAtRestCreateRequest } from './agent-session-at-rest-create' const NOW = 1_800_000_000_000 const NATIVE: AgentSessionExecutionLocation = { @@ -24,10 +24,8 @@ const NATIVE: AgentSessionExecutionLocation = { workspaceId: 'workspace-1', workspaceKind: 'git-worktree' } -const INDETERMINATE: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'no answer' } let directory: string -let counter = 0 beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-tab-table-')) @@ -37,30 +35,20 @@ afterEach(async () => { await rm(directory, { recursive: true, force: true }) }) -function operationId(now = NOW): string { - counter += 1 - return `${now}-${String(counter) - .padStart(32, '0') - .replaceAll(/[^0-9a-f]/g, '0')}` -} - -function reserveRequest( - overrides: Partial = {} -): AgentSessionReserveRequest { - return { +/** A chat's create, which is where a tab id is reserved. */ +function create( + store: AgentSessionRecordStore, + overrides: Partial> = {} +) { + return foundTestAgentSessionRecord(store, { sessionId: 'session-alpha', location: NATIVE, provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, - expectedFence: null, - spawnToken: 'spawn-a', claimKeyId: 'key-1', - handoffOperationId: null, - probe: INDETERMINATE, - operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-1' }, now: NOW, ...overrides - } + }) } async function open(): Promise { @@ -72,7 +60,7 @@ describe('chat tab table', () => { it('takes a reserved id only when the tab is shown, and refuses it to a second chat', async () => { const store = await open() - await store.reserveOwner(reserveRequest({ surfaceTabId: 'tab-alpha' })) + await create(store, { surfaceTabId: 'tab-alpha' }) // A create that dies before its tab is shown leaves nothing to restore or release. expect(store.getSessionTabId('session-alpha')).toBeNull() expect((await readPersistedTestAgentSessionStore(directory)).sessionTabs).toBeUndefined() @@ -85,27 +73,16 @@ describe('chat tab table', () => { expect(persisted.records['session-alpha']).not.toHaveProperty('surfaceTabId') await expect( - store.reserveOwner( - reserveRequest({ - sessionId: 'session-beta', - surfaceTabId: 'tab-alpha', - operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } - }) - ) + create(store, { sessionId: 'session-beta', surfaceTabId: 'tab-alpha' }) ).rejects.toThrow('agent_session_conflict') expect(store.getRecord('session-beta')).toBeNull() }) it('refuses showing a tab with the situation typed, as every chat refusal is', async () => { const store = await open() - await store.reserveOwner(reserveRequest({ surfaceTabId: 'tab-alpha' })) + await create(store, { surfaceTabId: 'tab-alpha' }) await store.setSessionTabVisibility('session-alpha', true, 'tab-alpha') - await store.reserveOwner( - reserveRequest({ - sessionId: 'session-beta', - operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } - }) - ) + await create(store, { sessionId: 'session-beta' }) // The message stays the code: readers of a thrown refusal treat it as one. await expect( @@ -126,30 +103,24 @@ describe('chat tab table', () => { it('frees a reserved id once its chat is hidden', async () => { const store = await open() - await store.reserveOwner(reserveRequest({ surfaceTabId: 'tab-alpha' })) + await create(store, { surfaceTabId: 'tab-alpha' }) await store.setSessionTabVisibility('session-alpha', true, 'tab-alpha') await store.setSessionTabVisibility('session-alpha', false) - await store.reserveOwner( - reserveRequest({ - sessionId: 'session-beta', - surfaceTabId: 'tab-alpha', - operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } - }) - ) + await create(store, { sessionId: 'session-beta', surfaceTabId: 'tab-alpha' }) await store.setSessionTabVisibility('session-beta', true, 'tab-alpha') expect(store.getSessionTabId('session-beta')).toBe('tab-alpha') }) it('refuses a tab id that could not prefix a pane key', async () => { const store = await open() - await expect( - store.reserveOwner(reserveRequest({ surfaceTabId: 'agent-session:session-alpha' })) - ).rejects.toThrow('agent_session_operation_invalid') + await expect(create(store, { surfaceTabId: 'agent-session:session-alpha' })).rejects.toThrow( + 'agent_session_operation_invalid' + ) }) it('gives a shown chat the id clients derive, and puts a hidden one back under its old id', async () => { const store = await open() - await store.reserveOwner(reserveRequest()) + await create(store) await store.setSessionTabVisibility('session-alpha', true) expect(store.getSessionTabId('session-alpha')).toBe(LEGACY_TAB_ID) await store.setSessionTabVisibility('session-alpha', false) diff --git a/src/main/runtime/agent-session-record-store-test-harness.ts b/src/main/runtime/agent-session-record-store-test-harness.ts index eae3ae3baf4c..972d6a8b0069 100644 --- a/src/main/runtime/agent-session-record-store-test-harness.ts +++ b/src/main/runtime/agent-session-record-store-test-harness.ts @@ -5,7 +5,9 @@ * through the test database registry, so `closeTestJournalHostDatabases` closes it too. */ +import { randomUUID } from 'node:crypto' import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' +import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { PersistedAgentSessionRecord } from '../../shared/agent-session-legacy-handoff-lease' import { JOURNAL_DB_SCHEMA_VERSION } from '../native-chat/agent-session-journal/journal-database-schema' import { @@ -14,6 +16,11 @@ import { } from '../native-chat/agent-session-journal/journal-host-database-test-support' import type Database from '../sqlite/sync-database' import { AgentSessionRecordStore } from './agent-session-record-store' +import type { AgentSessionAtRestCreateRequest } from './agent-session-at-rest-create' +import type { + AgentSessionReserveRequest, + AgentSessionReserveResult +} from './agent-session-reservation-admission' import { AGENT_SESSION_STORE_SCHEMA_VERSION, type RetiredAgentSessionClaimKey @@ -35,6 +42,43 @@ export type PersistedTestAgentSessionStore = { sessionTabs?: { tabId: string; sessionId: string }[] } +/** A record as production founds one: its create, at rest, under an operation of its own. */ +export async function foundTestAgentSessionRecord( + store: AgentSessionRecordStore, + request: Omit +): Promise { + const { record } = await store.createAtRest({ + ...request, + operation: { + callerKey: 'test:create', + operationId: `${request.now}-${randomUUID().replaceAll('-', '')}`, + fingerprint: 'test:create' + } + }) + return record +} + +/** A founded record, then reserved for its first start at the founded fence, as a start reserves. */ +export async function foundAndReserveTestAgentSessionRecord( + store: AgentSessionRecordStore, + request: Omit & + Pick +): Promise { + const founded = await foundTestAgentSessionRecord(store, { + sessionId: request.sessionId, + location: request.location, + provider: request.provider, + accountHome: request.accountHome, + ...(request.options ? { options: request.options } : {}), + ...(request.launchArgs ? { launchArgs: request.launchArgs } : {}), + ...(request.surfaceTabId ? { surfaceTabId: request.surfaceTabId } : {}), + ...(request.adoptedHandleLink ? { adoptedHandleLink: request.adoptedHandleLink } : {}), + claimKeyId: request.claimKeyId, + now: request.now + }) + return store.reserveOwner({ ...request, expectedFence: founded.lease.runtimeFence }) +} + function databaseFor(stateDirectory: string): Database.Database { return openTestJournalHostDatabase(stateDirectory).db } diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts index 12aa70b330cd..4094ab271a45 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -14,6 +14,8 @@ import type { PersistedAgentSessionRecord } from '../../shared/agent-session-leg import type { AgentSessionRecordStore } from './agent-session-record-store' import { editPersistedTestAgentSessionStore, + foundAndReserveTestAgentSessionRecord, + foundTestAgentSessionRecord, openTestAgentSessionRecordStore, readPersistedTestAgentSessionStore, seedTestAgentSessionStoreFromNewerBuild @@ -59,7 +61,8 @@ function reserveRequest( location: NATIVE, provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, - expectedFence: null, + // The fence a create founds a record at, which its first start reserves from. + expectedFence: 1, spawnToken: 'spawn-a', claimKeyId: 'key-1', handoffOperationId: null, @@ -89,7 +92,7 @@ function handleLink( linkId: 'link-1', handle: { provider: 'claude', sessionId: 'provider-session-1', leafUuid: 'leaf-1' }, origin: 'created', - mintedAtFence: 1, + mintedAtFence: 2, observedAt: NOW, ...overrides } @@ -97,6 +100,19 @@ function handleLink( let directory: string +/** A record founded at rest by its create, then reserved by its first start: fence 1, then 2. */ +function reserveFresh( + store: AgentSessionRecordStore, + overrides: Partial = {} +) { + return foundAndReserveTestAgentSessionRecord(store, reserveRequest(overrides)) +} + +/** The rows this suite's client wrote; each create writes one of its own. */ +function clientRows(store: AgentSessionRecordStore) { + return store.listOperationRows().filter((row) => row.callerKey === 'client-1') +} + async function open(hostId = 'local'): Promise { return openTestAgentSessionRecordStore(directory, { hostId }) } @@ -106,7 +122,7 @@ async function establishOwner( store: AgentSessionRecordStore, overrides: Partial = {} ): Promise { - const reserved = await store.reserveOwner(reserveRequest(overrides)) + const reserved = await reserveFresh(store, overrides) const fence = reserved.record.lease.runtimeFence const sessionId = reserved.record.sessionId await store.commitProcessIdentity({ @@ -130,7 +146,7 @@ async function markLegacyConflicted( lease: Partial = {} ): Promise { if (!store.getRecord('session-alpha')) { - await store.reserveOwner(reserveRequest()) + await reserveFresh(store) } await store.transitionHandoff('session-alpha', (record) => ({ ...record, @@ -154,10 +170,10 @@ afterEach(async () => { describe('acquisition path', () => { it('admits a writer only after reservation, observed identity, and a proved handle', async () => { const store = await open() - const reserved = await store.reserveOwner(reserveRequest()) - expect(reserved.disposition).toBe('created') + const reserved = await reserveFresh(store) + expect(reserved.disposition).toBe('reserved') expect(reserved.record.lease).toMatchObject({ - runtimeFence: 1, + runtimeFence: 2, claimStatus: 'reserved', handoffStage: 'new-owner-proving', ownerProcess: null, @@ -166,18 +182,18 @@ describe('acquisition path', () => { // Proving before the spawn is observed is refused. await expect( - store.proveOwner({ sessionId: 'session-alpha', fence: 1, link: handleLink(), now: NOW }) + store.proveOwner({ sessionId: 'session-alpha', fence: 2, link: handleLink(), now: NOW }) ).rejects.toThrow('agent_session_ownership_unknown') await store.commitProcessIdentity({ sessionId: 'session-alpha', - fence: 1, + fence: 2, process: processIdentity(), now: NOW }) const proved = await store.proveOwner({ sessionId: 'session-alpha', - fence: 1, + fence: 2, link: handleLink(), now: NOW }) @@ -185,18 +201,18 @@ describe('acquisition path', () => { claimStatus: 'live', handoffStage: null, provenHandleLinkId: 'link-1', - runtimeFence: 1 + runtimeFence: 2 }) expect(proved.providerHandleChain).toHaveLength(1) }) it('refuses a child that cannot echo the reserved spawn token', async () => { const store = await open() - await store.reserveOwner(reserveRequest()) + await reserveFresh(store) await expect( store.commitProcessIdentity({ sessionId: 'session-alpha', - fence: 1, + fence: 2, process: processIdentity({ spawnToken: 'spawn-other' }), now: NOW }) @@ -205,10 +221,10 @@ describe('acquisition path', () => { it('accepts provider proof only for the reserved provider at the current fence', async () => { const store = await open() - await store.reserveOwner(reserveRequest()) + await reserveFresh(store) await store.commitProcessIdentity({ sessionId: 'session-alpha', - fence: 1, + fence: 2, process: processIdentity(), now: NOW }) @@ -216,7 +232,7 @@ describe('acquisition path', () => { await expect( store.proveOwner({ sessionId: 'session-alpha', - fence: 1, + fence: 2, link: handleLink({ handle: { provider: 'codex', threadId: 'thread-1' } }), @@ -226,8 +242,8 @@ describe('acquisition path', () => { await expect( store.proveOwner({ sessionId: 'session-alpha', - fence: 1, - link: handleLink({ mintedAtFence: 2 }), + fence: 2, + link: handleLink({ mintedAtFence: 3 }), now: NOW }) ).rejects.toThrow('agent_session_provider_handle_stale_fence') @@ -240,7 +256,7 @@ describe('acquisition path', () => { await expect( store.proveOwner({ sessionId: 'session-alpha', - fence: 1, + fence: 2, link: handleLink({ linkId: 'link-2', origin: 'resumed', @@ -251,15 +267,15 @@ describe('acquisition path', () => { ).rejects.toThrow('agent_session_ownership_unknown') }) - it('refuses a create that carries a fence and a re-create that does not', async () => { + it('refuses a start of a record no create founded, and one from a fence the record left', async () => { const store = await open() - await expect(store.reserveOwner(reserveRequest({ expectedFence: 0 }))).rejects.toThrow( - 'agent_session_checkpoint_stale' - ) + await expect(store.reserveOwner(reserveRequest())).rejects.toMatchObject({ + refusal: { code: 'agent_session_checkpoint_stale', details: { reason: 'recordMissing' } } + }) await establishOwner(store) - await expect(store.reserveOwner(reserveRequest({ expectedFence: null }))).rejects.toThrow( - 'agent_session_conflict' - ) + await expect( + store.reserveOwner(reserveRequest({ probe: { outcome: 'pid-absent' } })) + ).rejects.toThrow('agent_session_checkpoint_stale') }) it.each([ @@ -275,7 +291,7 @@ describe('acquisition path', () => { store.reserveOwner( reserveRequest({ ...overrides, - expectedFence: 1, + expectedFence: 2, probe: { outcome: 'pid-absent' }, operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } }) @@ -290,7 +306,7 @@ describe('concurrent claims', () => { await establishOwner(store) const request = () => reserveRequest({ - expectedFence: 1, + expectedFence: 2, probe: { outcome: 'pid-absent' }, spawnToken: 'spawn-b', operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } @@ -303,37 +319,44 @@ describe('concurrent claims', () => { expect(granted).toHaveLength(1) const refused = results.find((result) => result.status === 'rejected') expect((refused as PromiseRejectedResult).reason.message).toBe('agent_session_checkpoint_stale') - expect(store.getRecord('session-alpha')?.lease.runtimeFence).toBe(2) + expect(store.getRecord('session-alpha')?.lease.runtimeFence).toBe(3) }) - it('serializes concurrent creates of the same session id', async () => { + it('serializes concurrent first starts of one founded session', async () => { const store = await open() + const { sessionId, location, provider, accountHome, claimKeyId } = reserveRequest() + await foundTestAgentSessionRecord(store, { + sessionId, + location, + provider, + accountHome, + claimKeyId, + now: NOW + }) const results = await Promise.allSettled([ store.reserveOwner(reserveRequest()), store.reserveOwner(reserveRequest()) ]) expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1) - expect(store.getRecord('session-alpha')?.lease.runtimeFence).toBe(1) + expect(store.getRecord('session-alpha')?.lease.runtimeFence).toBe(2) }) it('replays a retried operation id instead of reserving twice', async () => { const store = await open() const operation = { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-1' } - const first = await store.reserveOwner(reserveRequest({ operation })) + const first = await reserveFresh(store, { operation }) const second = await store.reserveOwner(reserveRequest({ operation })) expect(second.disposition).toBe('replayed') expect(second.record.lease.runtimeFence).toBe(first.record.lease.runtimeFence) - expect(store.listOperationRows()).toHaveLength(1) + expect(clientRows(store)).toHaveLength(1) }) it('refuses the same operation id carrying different parameters', async () => { const store = await open() const operationId_ = operationId() - await store.reserveOwner( - reserveRequest({ - operation: { callerKey: 'client-1', operationId: operationId_, fingerprint: 'fp-1' } - }) - ) + await reserveFresh(store, { + operation: { callerKey: 'client-1', operationId: operationId_, fingerprint: 'fp-1' } + }) await expect( store.reserveOwner( reserveRequest({ @@ -349,10 +372,10 @@ describe('concurrent claims', () => { await establishOwner(store) const before = store.getRecord('session-alpha') await expect( - store.reserveOwner(reserveRequest({ expectedFence: 1, probe: INDETERMINATE })) + store.reserveOwner(reserveRequest({ expectedFence: 2, probe: INDETERMINATE })) ).rejects.toThrow('agent_session_ownership_unknown') expect(store.getRecord('session-alpha')).toEqual(before) - expect(store.listOperationRows()).toHaveLength(1) + expect(clientRows(store)).toHaveLength(1) }) it('never keeps a change in memory that failed to commit to disk', async () => { @@ -381,7 +404,7 @@ describe('expiry is not eviction', () => { await expect( store.reserveOwner( reserveRequest({ - expectedFence: 1, + expectedFence: 2, probe, now: wellPastDeadline, operation: { @@ -393,16 +416,16 @@ describe('expiry is not eviction', () => { ) ).rejects.toThrow(/agent_session_(ownership_unknown|conflict)/) } - expect(store.getRecord('session-alpha')?.lease.runtimeFence).toBe(1) + expect(store.getRecord('session-alpha')?.lease.runtimeFence).toBe(2) // Only proof of death moves it. const evicted = await store.evictProvenDeadOwner({ sessionId: 'session-alpha', - expectedFence: 1, + expectedFence: 2, probe: { outcome: 'pid-absent' }, now: wellPastDeadline }) - expect(evicted.lease).toMatchObject({ runtimeFence: 2, claimStatus: 'released' }) + expect(evicted.lease).toMatchObject({ runtimeFence: 3, claimStatus: 'released' }) expect(evicted.lease.deathEvidence?.kind).toBe('pid-absent') }) @@ -412,7 +435,7 @@ describe('expiry is not eviction', () => { await expect( store.evictProvenDeadOwner({ sessionId: 'session-alpha', - expectedFence: 1, + expectedFence: 2, probe: INDETERMINATE, now: NOW }) @@ -424,7 +447,7 @@ describe('expiry is not eviction', () => { await establishOwner(store) const renewed = await store.renewLease({ sessionId: 'session-alpha', - fence: 1, + fence: 2, childProbe: MATCHED, now: NOW + 5_000 }) @@ -432,7 +455,7 @@ describe('expiry is not eviction', () => { await expect( store.renewLease({ sessionId: 'session-alpha', - fence: 1, + fence: 2, childProbe: { outcome: 'identity-matched', matchedOn: [] }, now: NOW + 10_000 }) @@ -455,12 +478,12 @@ describe('restart reconciliation', () => { }) // Every mutating path is closed while unreconciled. await expect( - reopened.renewLease({ sessionId: 'session-alpha', fence: 1, childProbe: MATCHED, now: NOW }) + reopened.renewLease({ sessionId: 'session-alpha', fence: 2, childProbe: MATCHED, now: NOW }) ).rejects.toThrow('execution_owner_reconciling') await expect( reopened.reserveOwner( reserveRequest({ - expectedFence: 1, + expectedFence: 2, probe: { outcome: 'pid-absent' }, operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } }) @@ -476,7 +499,7 @@ describe('restart reconciliation', () => { const record = reopened.getRecord('session-alpha') expect(record?.lease).toMatchObject({ unreconciled: false, - runtimeFence: 1, + runtimeFence: 2, claimStatus: 'live' }) expect(record?.lease.provenHandleLinkId).toBe('link-1') @@ -505,7 +528,7 @@ describe('restart reconciliation', () => { expect(await stale).toEqual(new Map()) const persisted = await readPersistedTestAgentSessionStore(directory) expect(persisted.records['session-alpha'].lease).toMatchObject({ - runtimeFence: 1, + runtimeFence: 2, claimStatus: 'live', ownerProcess: { pid: 4242, spawnToken: 'spawn-a' }, unreconciled: false @@ -517,7 +540,7 @@ describe('restart reconciliation', () => { await establishOwner(first) await first.evictProvenDeadOwner({ sessionId: 'session-alpha', - expectedFence: 1, + expectedFence: 2, probe: { outcome: 'exit-observed' }, now: NOW }) @@ -525,7 +548,7 @@ describe('restart reconciliation', () => { const second = await open() await second.reconcileOnRestart({ probe: async () => UNUSED, now: NOW + 1_000 }) const afterRestart = second.getRecord('session-alpha')?.lease.runtimeFence ?? 0 - expect(afterRestart).toBeGreaterThanOrEqual(2) + expect(afterRestart).toBeGreaterThanOrEqual(3) const reacquired = await second.reserveOwner( reserveRequest({ @@ -552,12 +575,12 @@ describe('restart reconciliation', () => { const reopened = await open() await reopened.reconcileOnRestart({ probe: async () => INDETERMINATE, now: NOW + 1_000 }) const lease = reopened.getRecord('session-alpha')?.lease - expect(lease).toMatchObject({ handoffStage: 'recovering', runtimeFence: 1 }) + expect(lease).toMatchObject({ handoffStage: 'recovering', runtimeFence: 2 }) expect(lease?.ownerProcess).not.toBeNull() await expect( reopened.reserveOwner( reserveRequest({ - expectedFence: 1, + expectedFence: 2, probe: { outcome: 'pid-absent' }, operation: { callerKey: 'client-1', @@ -621,7 +644,7 @@ describe('restart reconciliation', () => { }) const reacquired = await reopened.reserveOwner( reserveRequest({ - expectedFence: lease?.runtimeFence ?? null, + expectedFence: lease?.runtimeFence ?? 0, probe: { outcome: 'pid-absent' }, operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } }) @@ -631,12 +654,12 @@ describe('restart reconciliation', () => { it('frees a reservation that provably never spawned', async () => { const first = await open() - await first.reserveOwner(reserveRequest()) + await reserveFresh(first) const reopened = await open() await reopened.reconcileOnRestart({ probe: async () => UNUSED, now: NOW + 1_000 }) expect(reopened.getRecord('session-alpha')?.lease).toMatchObject({ claimStatus: 'released', - runtimeFence: 2, + runtimeFence: 3, reservedSpawnToken: null }) }) @@ -644,7 +667,7 @@ describe('restart reconciliation', () => { it('carries the operation ledger across a restart so a retry is still a replay', async () => { const operation = { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-1' } const first = await open() - await first.reserveOwner(reserveRequest({ operation })) + await reserveFresh(first, { operation }) await first.recordOperationOutcome({ callerKey: operation.callerKey, operationId: operation.operationId, @@ -652,7 +675,7 @@ describe('restart reconciliation', () => { }) const reopened = await open() - expect(reopened.listOperationRows()).toHaveLength(1) + expect(clientRows(reopened)).toHaveLength(1) const replayed = await reopened.reserveOwner(reserveRequest({ operation })) expect(replayed.disposition).toBe('replayed') expect(replayed.record.sessionId).toBe('session-alpha') @@ -672,7 +695,7 @@ describe('host and workspace isolation', () => { store.reserveOwner( reserveRequest({ location, - expectedFence: 1, + expectedFence: 2, probe: { outcome: 'pid-absent' }, operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } }) diff --git a/src/main/runtime/agent-session-released-reservation-replay.test.ts b/src/main/runtime/agent-session-released-reservation-replay.test.ts index e5f54265dcb7..f512783cb0ec 100644 --- a/src/main/runtime/agent-session-released-reservation-replay.test.ts +++ b/src/main/runtime/agent-session-released-reservation-replay.test.ts @@ -1,4 +1,4 @@ -// A create the host was running when it died is retried under the same operation id. Recovery has +// A start the host was running when it died is retried under the same operation id. Recovery has // released its reservation by then, so the retry continues it at the next fence; a reservation that // is still held keeps answering exactly as it did. @@ -13,7 +13,10 @@ import { import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionLease } from '../../shared/agent-session-record' import type { AgentSessionRecordStore } from './agent-session-record-store' -import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' +import { + foundAndReserveTestAgentSessionRecord, + openTestAgentSessionRecordStore +} from './agent-session-record-store-test-harness' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' const NOW = 1_800_000_000_000 @@ -37,7 +40,8 @@ function open(): Promise { return openTestAgentSessionRecordStore(directory) } -function createRequest( +/** The start of a record its create founded at fence 1. */ +function startRequest( overrides: Partial = {} ): AgentSessionReserveRequest { return { @@ -50,7 +54,7 @@ function createRequest( }, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, - expectedFence: null, + expectedFence: 1, spawnToken: 'spawn-a', claimKeyId: 'key-1', handoffOperationId: OPERATION, @@ -61,21 +65,21 @@ function createRequest( } } -/** The create reserved, then a restart that could prove nothing released it at fence 2. */ +/** The start reserved, then a restart that could prove nothing released it at fence 3. */ async function releasedByRestart(now: number): Promise { const first = await open() - await first.reserveOwner(createRequest()) + await foundAndReserveTestAgentSessionRecord(first, startRequest()) const store = await open() await store.reconcileOnRestart({ probe: async () => INDETERMINATE, now }) expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'released', handoffStage: null, - runtimeFence: 2 + runtimeFence: 3 }) return store } -describe('a create retried after recovery released its reservation', () => { +describe('a start retried after recovery released its reservation', () => { it.each([ ['its operation row is still pending', 1_000], ['its operation row has expired', PAST_EXPIRY] @@ -83,39 +87,39 @@ describe('a create retried after recovery released its reservation', () => { const now = NOW + elapsed const store = await releasedByRestart(now) - const continued = await store.reserveOwner(createRequest({ spawnToken: () => 'spawn-b', now })) + const continued = await store.reserveOwner(startRequest({ spawnToken: () => 'spawn-b', now })) expect(continued.disposition).toBe('reserved') expect(continued.record.lease).toMatchObject({ claimStatus: 'reserved', handoffStage: 'new-owner-proving', - runtimeFence: 3, + runtimeFence: 4, reservedSpawnToken: 'spawn-b', handoffOperationId: OPERATION }) - expect(store.listOperationRows()).toEqual([ + expect(store.listOperationRows().filter((row) => row.callerKey === 'client-1')).toEqual([ expect.objectContaining({ operationId: OPERATION, outcome: { status: 'pending' } }) ]) // A spawn from the first reservation reports in late: refused at its fence, and by token. const lateSpawn = { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken: 'spawn-a' } await expect( - store.commitProcessIdentity({ sessionId: SESSION, fence: 1, process: lateSpawn, now }) + store.commitProcessIdentity({ sessionId: SESSION, fence: 2, process: lateSpawn, now }) ).rejects.toThrow('agent_session_checkpoint_stale') await expect( - store.commitProcessIdentity({ sessionId: SESSION, fence: 3, process: lateSpawn, now }) + store.commitProcessIdentity({ sessionId: SESSION, fence: 4, process: lateSpawn, now }) ).rejects.toThrow('agent_session_ownership_unknown') // Retried again while that reservation stands: the same reservation, never a second spawn. const mint = vi.fn(() => 'spawn-c') - const retried = await store.reserveOwner(createRequest({ spawnToken: mint, now })) + const retried = await store.reserveOwner(startRequest({ spawnToken: mint, now })) expect(retried.disposition).toBe('replayed') - expect(retried.record.lease).toMatchObject({ runtimeFence: 3, reservedSpawnToken: 'spawn-b' }) + expect(retried.record.lease).toMatchObject({ runtimeFence: 4, reservedSpawnToken: 'spawn-b' }) expect(mint).not.toHaveBeenCalled() }) it('refuses an expired retry of a session that has no record', async () => { const store = await open() - await expect(store.reserveOwner(createRequest({ now: NOW + PAST_EXPIRY }))).rejects.toThrow( + await expect(store.reserveOwner(startRequest({ now: NOW + PAST_EXPIRY }))).rejects.toThrow( 'agent_session_operation_expired' ) }) @@ -125,30 +129,30 @@ describe('a create retried after recovery released its reservation', () => { ['one nothing could verify', INDETERMINATE, 'ownership_unknown'] ] as const)('still refuses a retry once its child is live and %s', async (_case, probe, code) => { const store = await open() - await store.reserveOwner(createRequest()) + await foundAndReserveTestAgentSessionRecord(store, startRequest()) await store.commitProcessIdentity({ sessionId: SESSION, - fence: 1, + fence: 2, process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken: 'spawn-a' }, now: NOW }) await store.proveOwner({ sessionId: SESSION, - fence: 1, + fence: 2, link: { linkId: 'link-1', handle: { provider: 'codex', threadId: 'thread-1' }, origin: 'created', - mintedAtFence: 1, + mintedAtFence: 2, observedAt: NOW }, now: NOW }) - await expect(store.reserveOwner(createRequest({ probe }))).rejects.toThrow( + await expect(store.reserveOwner(startRequest({ probe }))).rejects.toThrow( `agent_session_${code}` ) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', runtimeFence: 1 }) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ claimStatus: 'live', runtimeFence: 2 }) }) it.each<[string, Partial, string]>([ @@ -160,13 +164,13 @@ describe('a create retried after recovery released its reservation', () => { ] ])('still refuses a retry whose reservation is %s', async (_case, lease, code) => { const store = await open() - await store.reserveOwner(createRequest()) + await foundAndReserveTestAgentSessionRecord(store, startRequest()) await store.transitionHandoff(SESSION, (record) => ({ ...record, lease: { ...record.lease, ...lease } })) - await expect(store.reserveOwner(createRequest())).rejects.toThrow(code) - expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(1) + await expect(store.reserveOwner(startRequest())).rejects.toThrow(code) + expect(store.getRecord(SESSION)?.lease.runtimeFence).toBe(2) }) }) diff --git a/src/main/runtime/agent-session-reservation-admission.test.ts b/src/main/runtime/agent-session-reservation-admission.test.ts index cad534931e60..83d6827c1584 100644 --- a/src/main/runtime/agent-session-reservation-admission.test.ts +++ b/src/main/runtime/agent-session-reservation-admission.test.ts @@ -1,4 +1,5 @@ -// Adoption admission inside the reservation transaction: which conversation a new record may claim. +// Reservation admission for an agent start: the record its create founded, at the fence the start +// read. Founding, adoption and tab ids are the create's; see agent-session-at-rest-create. import { describe, expect, it } from 'vitest' import { @@ -10,7 +11,6 @@ import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' -import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import { applyAgentSessionReservation, type AgentSessionReserveRequest @@ -28,29 +28,15 @@ const LOCATION: AgentSessionExecutionLocation = { } const INDETERMINATE: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'no answer' } -/** The link an adopting create seeds: fence 1, because that is a new record's first. */ -function adoptedLink( - overrides: Partial = {} -): AgentSessionProviderHandleLink { - return { - linkId: 'claude-1-provider-session-alpha-1-empty', - handle: { provider: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: null }, - origin: 'adopted', - mintedAtFence: 1, - observedAt: NOW, - ...overrides - } -} - function reserveRequest( overrides: Partial = {} ): AgentSessionReserveRequest { return { - sessionId: 'session-adopting', + sessionId: 'session-starting', location: LOCATION, provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, - expectedFence: null, + expectedFence: 2, spawnToken: 'spawn-a', claimKeyId: 'key-1', handoffOperationId: null, @@ -73,153 +59,42 @@ function storeState(records: readonly AgentSessionRecord[] = []): AgentSessionSt } } -describe('adopted handle chain seeding', () => { - it('seeds a new record with the adopted link alone, at the first fence of the record', () => { - const link = adoptedLink() - const { record, disposition } = applyAgentSessionReservation( - storeState(), - reserveRequest({ adoptedHandleLink: link }), - LEASE_TTL_MS - ) - - expect(disposition).toBe('created') - expect(record.providerHandleChain).toEqual([link]) - // The owner probe requires the head link to carry the record's current fence. - expect(record.providerHandleChain[0]?.mintedAtFence).toBe(record.lease.runtimeFence) - }) - - it('leaves a blank create with no chain, so the adapter starts a conversation', () => { - const { record } = applyAgentSessionReservation(storeState(), reserveRequest(), LEASE_TTL_MS) - - expect(record.providerHandleChain).toEqual([]) - }) -}) - -describe('adopted conversation ownership', () => { - it('refuses when another record already holds the same conversation root', () => { - // The held link names a leaf; the adoption names none. Same root is the whole test: keying on - // the exact handle would let two writers onto one conversation on different branches. - const holder = agentSessionRecordFixture() - - expect(() => - applyAgentSessionReservation( - storeState([holder]), - reserveRequest({ adoptedHandleLink: adoptedLink() }), - LEASE_TTL_MS - ) - ).toThrow('agent_session_conflict') - }) - - it('admits an adoption of a conversation no record holds', () => { - const holder = agentSessionRecordFixture() +/** A record whose last start ended and released its lease at fence 2. */ +function released(): AgentSessionRecord { + return { + ...agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId: 'session-starting', + runtimeKind: 'native', + runtimeFence: 2, + provenHandleLinkId: null, + ownerProcess: null, + reservedSpawnToken: null, + claimStatus: 'released', + deathEvidence: { kind: 'exit-observed', detail: 'the start failed', observedAt: 1 } + }) + ), + location: LOCATION, + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + providerHandleChain: [] + } +} +describe('reserving a start', () => { + it('refuses a session no create founded, and one whose record this build cannot read', () => { expect(() => - applyAgentSessionReservation( - storeState([holder]), - reserveRequest({ - adoptedHandleLink: adoptedLink({ - handle: { provider: 'claude', sessionId: 'provider-session-other', leafUuid: null } - }) - }), - LEASE_TTL_MS - ) - ).not.toThrow() - }) - - it('exempts the requesting session so a committed create can be re-run', () => { - // Pins the guard's own contract. No wire shape reaches it today: `adopt` is accepted only on - // create-by-intent, which always carries a null expected fence, and an existing record with a - // null expected fence is refused a few lines below anyway. - const link = adoptedLink() - const committed: AgentSessionRecord = { - ...agentSessionRecordFixture( - agentSessionLeaseFixture({ - sessionId: 'session-adopting', - runtimeFence: 1, - handoffStage: 'new-owner-proving', - claimStatus: 'reserved', - ownerProcess: null, - provenHandleLinkId: null, - handoffOperationId: 'handoff-1' - }) - ), - location: LOCATION, - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, - providerHandleChain: [link] - } - - const { record, disposition } = applyAgentSessionReservation( - storeState([committed]), - reserveRequest({ - adoptedHandleLink: link, - expectedFence: 1, - handoffOperationId: 'handoff-1' - }), - LEASE_TTL_MS + applyAgentSessionReservation(storeState(), reserveRequest(), LEASE_TTL_MS) + ).toThrow('agent_session_checkpoint_stale') + const unreadable = storeState() + unreadable.unreadableRecords.set('session-starting', { reason: 'invalid', raw: {} }) + expect(() => applyAgentSessionReservation(unreadable, reserveRequest(), LEASE_TTL_MS)).toThrow( + 'execution_owner_reconciling' ) - - expect(disposition).toBe('retry-reservation') - expect(record.providerHandleChain).toEqual([link]) - }) - - it('refuses a Codex adoption another record already holds', () => { - const holder: AgentSessionRecord = { - ...agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId: 'session-codex' })), - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, - providerHandleChain: [ - { - linkId: 'codex-1-thread-1', - handle: { provider: 'codex', threadId: 'thread-1' }, - origin: 'created', - mintedAtFence: 7, - observedAt: NOW - } - ] - } - - expect(() => - applyAgentSessionReservation( - storeState([holder]), - reserveRequest({ - sessionId: 'session-codex-adopting', - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, - adoptedHandleLink: adoptedLink({ - linkId: 'codex-1-thread-1-adopted', - handle: { provider: 'codex', threadId: 'thread-1' } - }) - }), - LEASE_TTL_MS - ) - ).toThrow('agent_session_conflict') }) -}) -describe('re-create over a failed create', () => { - const EXITED = agentSessionLeaseFixture({ - sessionId: 'session-adopting', - runtimeKind: 'native', - runtimeFence: 2, - provenHandleLinkId: null, - ownerProcess: null, - reservedSpawnToken: null, - claimStatus: 'released', - deathEvidence: { kind: 'exit-observed', detail: 'the create failed', observedAt: 1 } - }) - function failedCreate(overrides: Partial = {}): AgentSessionRecord { - return { - ...agentSessionRecordFixture(EXITED), - location: LOCATION, - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, - providerHandleChain: [], - ...overrides - } - } - - it('reserves a record that never bound a conversation and whose attempt is proven gone', () => { + it('reserves a released record one fence past the fence its start read', () => { const { record, disposition } = applyAgentSessionReservation( - storeState([failedCreate()]), + storeState([released()]), reserveRequest(), LEASE_TTL_MS ) @@ -228,18 +103,13 @@ describe('re-create over a failed create', () => { expect(record.lease).toMatchObject({ claimStatus: 'reserved', runtimeFence: 3 }) }) - it('refuses when the record bound a conversation, or its attempt may still run', () => { - const bound = failedCreate({ providerHandleChain: [adoptedLink()] }) - const unproven = failedCreate({ - lease: { ...EXITED, claimStatus: 'reserved', handoffStage: 'recovering' } - }) - // Released so a send can start over, but nothing proved the attempt gone. - const releasedUnproven = failedCreate({ lease: { ...EXITED, deathEvidence: null } }) - - for (const record of [bound, unproven, releasedUnproven]) { - expect(() => - applyAgentSessionReservation(storeState([record]), reserveRequest(), LEASE_TTL_MS) - ).toThrow('agent_session_conflict') - } + it('refuses a start that read a fence the record has since left', () => { + expect(() => + applyAgentSessionReservation( + storeState([released()]), + reserveRequest({ expectedFence: 1 }), + LEASE_TTL_MS + ) + ).toThrow('agent_session_checkpoint_stale') }) }) diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index 9878f1f25b6a..7b2337038214 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -20,7 +20,6 @@ import { } from '../../shared/agent-session-operation-ledger' import { agentSessionLeaseIsReleased, - agentSessionLeaseOwnerVerdict, evaluateAgentSessionAcquisition, type AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' @@ -35,18 +34,12 @@ import { type AgentSessionRecord } from '../../shared/agent-session-record' import { isAgentSessionLaunchArgs } from '../../shared/agent-session-launch-args' -import { isAgentSessionSurfaceTabId } from '../../shared/agent-session-surface-tab-id' -import { - agentSessionProviderHandleRoot, - type AgentSessionHandleProvider, - type AgentSessionProviderHandleLink -} from '../../shared/agent-session-provider-handle' +import type { AgentSessionHandleProvider } from '../../shared/agent-session-provider-handle' import { reserveAgentSessionOwner, type AgentSessionReservation } from './agent-session-lease-transitions' import type { AgentSessionStoreState } from './agent-session-record-store-file' -import { agentSessionRecordIdentityFields } from './agent-session-record-founding' export type AgentSessionReserveRequest = { sessionId: string @@ -59,14 +52,8 @@ export type AgentSessionReserveRequest = { launchEnv?: AgentSessionLaunchEnv /** Initial provider options persisted before the first process is acquired. */ options?: Readonly> - /** The tab id a create reserved for this conversation, taken when its tab is published. An id - * another session's tab holds is refused here, before anything is spawned. */ - surfaceTabId?: string - /** Set only when this create adopts an existing provider conversation. Seeds the handle chain so - * the adapter resumes; without it a new record has never proved a thread and starts a fresh one. */ - adoptedHandleLink?: AgentSessionProviderHandleLink - /** Null when the session does not exist yet; otherwise the fence the caller last observed. */ - expectedFence: number | null + /** The fence of the record this start read. A record is founded by its create, never here. */ + expectedFence: number /** A supplier is invoked only when this operation wins a new reservation. */ spawnToken: string | (() => string) claimKeyId: string @@ -77,11 +64,7 @@ export type AgentSessionReserveRequest = { leaseTtlMs?: number } -export type AgentSessionReserveDisposition = - | 'created' - | 'reserved' - | 'retry-reservation' - | 'replayed' +export type AgentSessionReserveDisposition = 'reserved' | 'retry-reservation' | 'replayed' export type AgentSessionReserveResult = { record: AgentSessionRecord @@ -165,21 +148,11 @@ export function applyAgentSessionReservation( leaseTtlMs: request.leaseTtlMs ?? leaseTtlMs, now: request.now } - // Inside the transaction, not only in the RPC resolver: two concurrent adoptions of one - // conversation mint different session ids, so the compare-and-swap never collides and a - // pre-commit check passes for both. Codex would then hold one thread from two app-servers, which - // it permits silently and which corrupts the conversation rather than erroring. - assertAdoptedConversationUnowned(state, request) const existing = state.records.get(request.sessionId) if (!existing) { - if (state.unreadableRecords.has(request.sessionId)) { - throw agentSessionRefusalError('execution_owner_reconciling', { reason: 'recordUnreadable' }) - } - if (request.expectedFence !== null) { - throw agentSessionRefusalError('agent_session_checkpoint_stale', { reason: 'recordMissing' }) - } - assertReservedTabUnheld(state, request) - return { record: createAgentSessionRecord(request, reservation), disposition: 'created' } + throw state.unreadableRecords.has(request.sessionId) + ? agentSessionRefusalError('execution_owner_reconciling', { reason: 'recordUnreadable' }) + : agentSessionRefusalError('agent_session_checkpoint_stale', { reason: 'recordMissing' }) } if ( !agentSessionExecutionLocationsEqual(existing.location, request.location) || @@ -190,16 +163,6 @@ export function applyAgentSessionReservation( // Why: location, provider, and account are the session identity; changing one is a fork. throw agentSessionRefusalError('agent_session_conflict', { reason: 'identityMismatch' }) } - // A create may take over only a record that never bound a conversation and whose last - // attempt is proven gone: that is the same as creating it fresh, under a fresh provider id. - const recreatable = - existing.providerHandleChain.length === 0 && - !request.adoptedHandleLink && - agentSessionLeaseOwnerVerdict(existing.lease) === 'exited' - if (request.expectedFence === null && !recreatable) { - throw agentSessionRefusalError('agent_session_conflict', { reason: 'sessionExists' }) - } - assertReservedTabUnheld(state, request) const pinned = { ...existing, ...(!existing.launchArgs && request.launchArgs ? { launchArgs: [...request.launchArgs] } : {}), @@ -207,102 +170,12 @@ export function applyAgentSessionReservation( } return reserveAgentSessionOwner({ record: pinned, - expectedFence: request.expectedFence ?? existing.lease.runtimeFence, + expectedFence: request.expectedFence, probe: request.probe, reservation }) } -/** - * Refuse an adoption whose conversation ANOTHER record already holds. - * - * The self-exemption is part of that definition, not a replay mechanism: replay is settled earlier - * by the operation ledger, and an adoption always arrives with a null expected fence, so a request - * naming an existing session id is refused a few lines below regardless. Keeping the scan scoped to - * other records is what makes this guard mean what its name says. - * - * It runs inside the store transaction because the pre-commit check in the RPC resolver cannot be - * the guard: two concurrent adoptions of one conversation mint different session ids, so the - * compare-and-swap never collides and both would pass. Codex permits two app-servers on one thread - * silently, so the cost of missing this is a corrupted conversation rather than an error. - */ -export function assertAdoptedConversationUnowned( - state: AgentSessionStoreState, - request: Pick -): void { - const adopted = request.adoptedHandleLink - if (!adopted) { - return - } - const root = agentSessionProviderHandleRoot(adopted.handle) - for (const record of state.records.values()) { - if (record.sessionId === request.sessionId) { - continue - } - const holdsSameConversation = record.providerHandleChain.some( - (link) => agentSessionProviderHandleRoot(link.handle) === root - ) - if (holdsSameConversation) { - throw agentSessionRefusalError('agent_session_conflict', { - reason: 'conversationHeldElsewhere' - }) - } - } -} - -/** - * A tab id names one conversation, so a reserved id another session's tab holds is a conflict. - * Checked, not claimed: the id is taken when the chat's tab is published, so a create that never - * gets that far leaves nothing in the table to restore or release. - */ -export function assertReservedTabUnheld( - state: AgentSessionStoreState, - request: Pick -): void { - if (request.surfaceTabId === undefined) { - return - } - if (!isAgentSessionSurfaceTabId(request.surfaceTabId)) { - throw agentSessionRefusalError('agent_session_operation_invalid', { - reason: 'requestMalformed' - }) - } - const holder = state.sessionTabs?.sessionIdFor(request.surfaceTabId) - if (holder !== undefined && holder !== request.sessionId) { - throw agentSessionRefusalError('agent_session_conflict', { reason: 'tabIdTaken' }) - } -} - -function createAgentSessionRecord( - request: AgentSessionReserveRequest, - reservation: AgentSessionReservation -): AgentSessionRecord { - return { - ...agentSessionRecordIdentityFields(request, request.now), - // Fence 1 below is this record's first, and the owner probe requires the head link to carry the - // record's current fence — so an adopted link must be minted at that same fence. - providerHandleChain: request.adoptedHandleLink ? [request.adoptedHandleLink] : [], - lease: { - sessionId: request.sessionId, - runtimeKind: 'native', - // Why: fence 1 is the first reservation; 0 is reserved for "no owner has ever existed". - runtimeFence: 1, - handoffStage: 'new-owner-proving', - provenHandleLinkId: null, - ownerProcess: null, - reservedSpawnToken: reservation.spawnToken, - leaseDeadlineAt: reservation.now + reservation.leaseTtlMs, - lastRenewedAt: reservation.now, - handoffOperationId: reservation.handoffOperationId, - journalCheckpoint: null, - claimKeyId: reservation.claimKeyId, - claimStatus: 'reserved', - unreconciled: false, - deathEvidence: null - } - } -} - /** * Compare-and-swap reservation plus its client-operation row, committed together. A replayed * operation returns the recorded outcome and never reaches the reservation. diff --git a/src/main/runtime/agent-session-store-transactions.test.ts b/src/main/runtime/agent-session-store-transactions.test.ts index 5ab6cad9e028..dcd58702117e 100644 --- a/src/main/runtime/agent-session-store-transactions.test.ts +++ b/src/main/runtime/agent-session-store-transactions.test.ts @@ -13,6 +13,7 @@ import { } from '../native-chat/agent-session-journal/journal-host-database-test-support' import type { AgentSessionRecordStore } from './agent-session-record-store' import { + foundAndReserveTestAgentSessionRecord, openTestAgentSessionRecordStore, readPersistedTestAgentSessionStore } from './agent-session-record-store-test-harness' @@ -37,7 +38,7 @@ async function liveChat( sessionId: string ): Promise { counter += 1 - const reserved = await store.reserveOwner({ + const reserved = await foundAndReserveTestAgentSessionRecord(store, { sessionId, location: { executionHostId: 'local', @@ -47,7 +48,6 @@ async function liveChat( }, provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, - expectedFence: null, spawnToken: `spawn-${counter}`, claimKeyId: 'key-1', handoffOperationId: null, diff --git a/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts b/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts index 31e473fb5e66..fc059e3411bb 100644 --- a/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts +++ b/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts @@ -5,7 +5,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import { codexProviderHandleLink } from '../codex/codex-structured-owner-identity' import type { AgentSessionRecordStore } from './agent-session-record-store' -import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' +import { + foundAndReserveTestAgentSessionRecord, + openTestAgentSessionRecordStore +} from './agent-session-record-store-test-harness' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' const NOW = 1_800_000_000_000 @@ -27,7 +30,7 @@ function reserveRequest( }, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, - expectedFence: null, + expectedFence: 1, spawnToken: `spawn-${operations}`, claimKeyId: 'key-1', handoffOperationId: null, @@ -42,13 +45,16 @@ function reserveRequest( } } -/** Reserve, observe the spawn and prove the link the adapter reported, as the host does. */ +/** Reserve (founding the record first, as its create does), observe the spawn and prove the link + * the adapter reported, as the host does. */ async function prove( store: AgentSessionRecordStore, request: AgentSessionReserveRequest, link: (fence: number) => AgentSessionProviderHandleLink ) { - const { record } = await store.reserveOwner(request) + const { record } = store.getRecord(SESSION) + ? await store.reserveOwner(request) + : await foundAndReserveTestAgentSessionRecord(store, request) const fence = record.lease.runtimeFence await store.commitProcessIdentity({ sessionId: SESSION, diff --git a/src/main/runtime/structured-agent-session-record-import-install.test.ts b/src/main/runtime/structured-agent-session-record-import-install.test.ts index 551fab477c41..866e9d88063d 100644 --- a/src/main/runtime/structured-agent-session-record-import-install.test.ts +++ b/src/main/runtime/structured-agent-session-record-import-install.test.ts @@ -12,6 +12,7 @@ import { journalPragmaNumber } from '../native-chat/agent-session-journal/journa import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' import { legacyAgentSessionStorePath } from './agent-session-record-store-file' +import { foundAndReserveTestAgentSessionRecord } from './agent-session-record-store-test-harness' import { OrcaRuntimeService } from './orca-runtime' import { ensureStructuredAgentSessionHost, @@ -117,7 +118,7 @@ it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( expect(first.unverifiable()).toBe(true) expect(databaseVersion()).toBe(3) // A chat started meanwhile lives in the database and keeps its row through the copy. - await host.deps.store.reserveOwner({ + await foundAndReserveTestAgentSessionRecord(host.deps.store, { sessionId: CREATED_WHILE_OWED, location: { executionHostId: 'local', @@ -127,7 +128,6 @@ it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( }, provider: 'claude', accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, - expectedFence: null, spawnToken: 'spawn-new', claimKeyId: 'key-1', handoffOperationId: null, diff --git a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts index d86c54282c10..3bb92f16e812 100644 --- a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts +++ b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts @@ -10,7 +10,10 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CODEX_SPAWN_TOKEN_ENV } from '../codex/codex-structured-owner-identity' import type { AgentSessionRecordStore } from './agent-session-record-store' -import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' +import { + foundAndReserveTestAgentSessionRecord, + openTestAgentSessionRecordStore +} from './agent-session-record-store-test-harness' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' import { createStructuredAgentSessionOwnerProbe } from './structured-agent-session-owner-probe' import { @@ -51,7 +54,7 @@ function environ(token: string | null): string { return ['PATH=/usr/bin', ...(token ? [`${CODEX_SPAWN_TOKEN_ENV}=${token}`] : [])].join('\0') } -function reserveRequest(): AgentSessionReserveRequest { +function reserveRequest(): Omit { const operationId = `${NOW}-${'1'.padStart(32, '0')}` return { sessionId: SESSION, @@ -63,7 +66,6 @@ function reserveRequest(): AgentSessionReserveRequest { }, provider: 'codex', accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, - expectedFence: null, spawnToken: MINTED_TOKEN, claimKeyId: 'key-1', handoffOperationId: null, @@ -99,7 +101,7 @@ describe('a process that inherited a spawn token', () => { it('is never signalled when the host installs and reconciles, even when this store minted the token', async () => { // A chat ran and ended: its root exited and the lease was released, clearing the token. const seed = await openStore() - const reserved = await seed.reserveOwner(reserveRequest()) + const reserved = await foundAndReserveTestAgentSessionRecord(seed, reserveRequest()) const fence = reserved.record.lease.runtimeFence await seed.commitProcessIdentity({ sessionId: SESSION, @@ -155,7 +157,7 @@ describe('a process that inherited a spawn token', () => { describe('the reservation owner probe', () => { async function reconcileReservation(): Promise { const crashed = await openStore() - await crashed.reserveOwner(reserveRequest()) + await foundAndReserveTestAgentSessionRecord(crashed, reserveRequest()) // The restart after a crash between reservation and recorded identity. const store = await openStore() await store.reconcileOnRestart({ From 51387f9365a6d0e51a08a4f18c6aba0c9205997d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:01:24 -0700 Subject: [PATCH 05/84] fix(native-chat): a create founds again a record whose agent never ran and whose start is proven gone An older host started the agent at create and left the record behind when that start failed: released, its exit proven, no conversation bound. The renderer's relaunch of that chat sends the same session id under a new operation, which the at-rest create refused as sessionExists on every message. The create now founds such a record again at rest, at its own fence, keeping its name, so the chat starts on its next message after an upgrade. --- .../agent-session-at-rest-create.test.ts | 81 +++++++++++++++++++ .../runtime/agent-session-at-rest-create.ts | 42 +++++++++- 2 files changed, 120 insertions(+), 3 deletions(-) diff --git a/src/main/runtime/agent-session-at-rest-create.test.ts b/src/main/runtime/agent-session-at-rest-create.test.ts index e6403e349e52..4291d71ac4a7 100644 --- a/src/main/runtime/agent-session-at-rest-create.test.ts +++ b/src/main/runtime/agent-session-at-rest-create.test.ts @@ -164,6 +164,87 @@ describe('a create at rest', () => { }) }) +/** What a create on an older host left when the start it ran at create failed: released at the + * fence that start reserved, its exit proven, no conversation bound. */ +function failedCreateFromOlderHost( + overrides: Partial = {} +): AgentSessionRecord { + return { + ...agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId: SESSION, + runtimeKind: 'native', + runtimeFence: 2, + provenHandleLinkId: null, + ownerProcess: null, + reservedSpawnToken: null, + claimStatus: 'released', + deathEvidence: { kind: 'exit-observed', detail: 'the create failed', observedAt: 1 } + }) + ), + location: LOCATION, + accountHome: ACCOUNT_HOME, + providerHandleChain: [], + conversationName: 'Fix the flaky test', + ...overrides + } +} + +describe('a create over a record an older host left after its start failed', () => { + it('founds it again at rest, at its own fence, keeping its name', () => { + const existing = failedCreateFromOlderHost() + const state = storeState([existing]) + + const { record, replayed } = commitAgentSessionAtRestCreate(state, createRequest()) + + expect(replayed).toBe(false) + expect(record).toMatchObject({ + providerHandleChain: [], + conversationName: 'Fix the flaky test', + createdAt: existing.createdAt + }) + expect(record.lease).toMatchObject({ + runtimeFence: 2, + claimStatus: 'released', + handoffStage: null, + deathEvidence: null + }) + }) + + it('refuses one that bound a conversation, may still run, or is another identity', () => { + const exited = failedCreateFromOlderHost().lease + const records = [ + failedCreateFromOlderHost({ providerHandleChain: [adoptedLink({ mintedAtFence: 2 })] }), + failedCreateFromOlderHost({ + lease: { ...exited, claimStatus: 'reserved', handoffStage: 'recovering' } + }), + // Released so a send can start over, but nothing proved the attempt gone. + failedCreateFromOlderHost({ lease: { ...exited, deathEvidence: null } }), + failedCreateFromOlderHost({ + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' } + }) + ] + for (const record of records) { + expect(() => commitAgentSessionAtRestCreate(storeState([record]), createRequest())).toThrow( + expect.objectContaining({ + refusal: expect.objectContaining({ details: { reason: 'sessionExists' } }) + }) + ) + } + // An adoption never takes over a record: it names a conversation of its own. + expect(() => + commitAgentSessionAtRestCreate( + storeState([failedCreateFromOlderHost()]), + createRequest({ adoptedHandleLink: adoptedLink() }) + ) + ).toThrow( + expect.objectContaining({ + refusal: expect.objectContaining({ details: { reason: 'sessionExists' } }) + }) + ) + }) +}) + describe('an adopting create at rest', () => { it('seeds the chain with the adopted link alone, at the record fence its first start moves', () => { const link = adoptedLink() diff --git a/src/main/runtime/agent-session-at-rest-create.ts b/src/main/runtime/agent-session-at-rest-create.ts index 8486deaaf17d..72a93a58180f 100644 --- a/src/main/runtime/agent-session-at-rest-create.ts +++ b/src/main/runtime/agent-session-at-rest-create.ts @@ -7,7 +7,12 @@ import { pendingAgentSessionOperationRow, type AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' -import { isAgentSessionOptions, type AgentSessionRecord } from '../../shared/agent-session-record' +import { agentSessionLeaseOwnerVerdict } from '../../shared/agent-session-lease-adjudication' +import { + agentSessionExecutionLocationsEqual, + isAgentSessionOptions, + type AgentSessionRecord +} from '../../shared/agent-session-record' import { isAgentSessionLaunchArgs } from '../../shared/agent-session-launch-args' import { isAgentSessionSurfaceTabId } from '../../shared/agent-session-surface-tab-id' import { @@ -64,12 +69,22 @@ export function commitAgentSessionAtRestCreate( if (state.unreadableRecords.has(request.sessionId)) { throw agentSessionRefusalError('execution_owner_reconciling', { reason: 'recordUnreadable' }) } - if (state.records.has(request.sessionId)) { + const existing = state.records.get(request.sessionId) + if (existing && !refoundable(existing, request)) { throw agentSessionRefusalError('agent_session_conflict', { reason: 'sessionExists' }) } assertAdoptedConversationUnowned(state, request) assertReservedTabUnheld(state, request) - const record = foundAgentSessionRecord(request, request, request.adoptedHandleLink) + const founded = foundAgentSessionRecord(request, request, request.adoptedHandleLink) + // Refounded keeps what the chat already has (its name) and its fence: a fence never moves back. + const record = existing + ? { + ...existing, + ...founded, + createdAt: existing.createdAt, + lease: { ...founded.lease, runtimeFence: existing.lease.runtimeFence } + } + : founded const operationRow: AgentSessionOperationRow = { ...pendingAgentSessionOperationRow({ ...request.operation, now: request.now }), outcome: { status: 'succeeded', sessionId: request.sessionId } @@ -82,6 +97,27 @@ export function commitAgentSessionAtRestCreate( return { record, operationRow, replayed: false } } +/** + * A record a create may found again at rest: its agent never bound a conversation and its last + * start is proven gone, so founding it again is founding it fresh. An older host left exactly this + * when the start it ran at create failed, and the client's relaunch of that chat (same session id, + * new operation) must get a chat, not `sessionExists` on every message. + */ +function refoundable( + existing: AgentSessionRecord, + request: AgentSessionAtRestCreateRequest +): boolean { + return ( + !request.adoptedHandleLink && + existing.providerHandleChain.length === 0 && + agentSessionLeaseOwnerVerdict(existing.lease) === 'exited' && + agentSessionExecutionLocationsEqual(existing.location, request.location) && + existing.provider === request.provider && + existing.accountHome.variable === request.accountHome.variable && + existing.accountHome.path === request.accountHome.path + ) +} + /** * Refuse an adoption whose conversation ANOTHER record already holds. * From e4ef29cc9819acc756581c90fcd0e54856f92a1f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:07:04 -0700 Subject: [PATCH 06/84] fix(native-chat): a create stays pending until its journal is open and its history imported The create used to write its operation row as succeeded in the same transaction as the record, before it opened the journal and imported an adopted conversation. A create cut short between the two (the journal open or import throwing, a crash, a quit) replayed as an empty chat whose first message resumed a conversation the user could not see. The row now stays pending until the journal is open and the history imported; a replay of a pending row does that work again (the import is a no-op on a journal that already holds rows) and then settles it. The failure path no longer rewrites the row to failed, so a transient failure heals on the client's retry. --- ...tured-agent-session-adopted-import.test.ts | 76 +++++++++++-------- ...structured-agent-session-create-at-rest.ts | 42 +++++----- .../agent-session-at-rest-create.test.ts | 4 + .../runtime/agent-session-at-rest-create.ts | 8 +- 4 files changed, 69 insertions(+), 61 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index 2ccdc2e901ff..b45dc173d193 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -215,40 +215,52 @@ describe('adopting a provider conversation on create', () => { } ) - // The import runs after the at-rest record commits, so its failure rejects the create but the - // conversation stays open for the next send. - it('rejects a create whose import write fails, and leaves the conversation writable', async () => { - root = await mkdtemp(join(tmpdir(), 'orca-adopt-write-failure-')) - const transcriptPath = join(root, 'rollout.jsonl') - await writeCodexRollout(transcriptPath, 'valid source') - vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems').mockRejectedValueOnce( - new Error('disk write failed') - ) - const sessionAdapter = adapter() - await expect(create(transcriptPath, sessionAdapter)).rejects.toThrow('disk write failed') - expect(sessionAdapter.acquire).not.toHaveBeenCalled() - // Settled as failed: a retry of the same create is told so, not handed an empty chat. - expect(await create(transcriptPath, sessionAdapter)).toMatchObject({ ok: false }) + // The import runs after the at-rest record commits. A create cut short there (a throw, a crash, a + // quit) leaves its operation pending, so its replay imports again rather than answering empty. + it.each([ + [ + 'its import write', + () => + vi + .spyOn(AgentSessionJournal.prototype, 'replaceEpochItems') + .mockRejectedValueOnce(new Error('disk write failed')), + 'disk write failed' + ], + [ + 'its journal open', + (sessionAdapter: StructuredAgentSessionAdapter) => { + sessionAdapter.historyFilePath = vi + .fn>() + .mockRejectedValueOnce(new Error('journal path unavailable')) + .mockResolvedValue(null) + }, + 'journal path unavailable' + ] + ] as const)( + 'imports the history on the replay of a create whose %s failed', + async (_case, interrupt, message) => { + root = await mkdtemp(join(tmpdir(), 'orca-adopt-interrupted-')) + const transcriptPath = join(root, 'rollout.jsonl') + await writeCodexRollout(transcriptPath, 'token ORCA-ADOPT-REPLAYED') + const sessionAdapter = adapter() + interrupt(sessionAdapter) + + await expect(create(transcriptPath, sessionAdapter)).rejects.toThrow(message) + expect(store?.getOperationRow('client-1', OPERATION)?.outcome).toEqual({ status: 'pending' }) - const body = { kind: 'message' as const, role: 'user' as const, blocks: [] } - const sent = await host!.send( - { callerKey: 'client-1' }, - { - envelope: { - sessionId: SESSION, - clientOperationId: `${NOW}-${'2'.padStart(32, '0')}`, - expectedRuntimeFence: null, - payloadFingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: SESSION, - fields: { body } - }) - }, - body + const replay = await create(transcriptPath, sessionAdapter) + expect(replay).toMatchObject({ ok: true, replayed: true }) + if (!replay.ok) { + throw new Error('create replay failed') } - ) - expect(sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) - }) + expect(JSON.stringify(replay.value.page.items)).toContain('ORCA-ADOPT-REPLAYED') + expect(store?.getOperationRow('client-1', OPERATION)?.outcome).toEqual({ + status: 'succeeded', + sessionId: SESSION + }) + expect(sessionAdapter.acquire).not.toHaveBeenCalled() + } + ) it('prepares a valid source once before the record claims it and imports those exact items', async () => { root = await mkdtemp(join(tmpdir(), 'orca-adopt-once-')) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create-at-rest.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create-at-rest.ts index ad424027bb34..fe2153e49b22 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create-at-rest.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create-at-rest.ts @@ -88,8 +88,12 @@ async function createAtRest( } catch (error) { return { ok: false, refusal: classifyStoreFailure(error, null, store.getRecord(sessionId)) } } - if (created.replayed && created.operationRow.outcome.status !== 'succeeded') { - // A create from before chats were created at rest: it answers only as it ended. + // Settled only once the journal is open and any adopted history is in it. A create that stopped + // short (a throw, a crash, a quit) leaves its row pending, and its replay does that work again: + // the import is a no-op on a journal that already holds rows. + const settled = created.operationRow.outcome.status === 'succeeded' + if (created.replayed && !settled) { + // `failed` or `unknown` rows are a create from before chats were created at rest. const replay = resolveAgentSessionReplayOutcome({ operationId: params.envelope.clientOperationId, outcome: created.operationRow.outcome, @@ -111,28 +115,18 @@ async function createAtRest( } } const { journal } = conversation - if (!created.replayed) { - try { - await importAdoptedTranscript( - params, - { journal, unconfirmedClientMessageIds: [] }, - created.record, - transcript.items - ) - } catch (error) { - // Settled before rethrowing, so a retry of this create replays the failure, not a success. - await store.recordOperationOutcome({ - callerKey, - operationId: params.envelope.clientOperationId, - outcome: { - status: 'failed', - code: 'agent_session_operation_invalid', - details: { reason: 'attachFailed' }, - message: error instanceof Error ? error.message : String(error) - } - }) - throw error - } + if (!settled) { + await importAdoptedTranscript( + params, + { journal, unconfirmedClientMessageIds: [] }, + created.record, + transcript.items + ) + await store.recordOperationOutcome({ + callerKey, + operationId: params.envelope.clientOperationId, + outcome: { status: 'succeeded', sessionId: created.record.sessionId } + }) } const fence = created.record.lease.runtimeFence const tabId = store.getSessionTabId(created.record.sessionId) diff --git a/src/main/runtime/agent-session-at-rest-create.test.ts b/src/main/runtime/agent-session-at-rest-create.test.ts index 4291d71ac4a7..34276079d8ca 100644 --- a/src/main/runtime/agent-session-at-rest-create.test.ts +++ b/src/main/runtime/agent-session-at-rest-create.test.ts @@ -105,6 +105,10 @@ describe('a create at rest', () => { deathEvidence: null }) expect(state.records.get(SESSION)).toEqual(record) + // Settled by the create once its journal is open; see structured-agent-session-create-at-rest. + expect([...state.operations.values()].map((row) => row.outcome)).toEqual([ + { status: 'pending' } + ]) }) it('replays the record its own operation founded, and writes nothing more', () => { diff --git a/src/main/runtime/agent-session-at-rest-create.ts b/src/main/runtime/agent-session-at-rest-create.ts index 72a93a58180f..914cfa887cfc 100644 --- a/src/main/runtime/agent-session-at-rest-create.ts +++ b/src/main/runtime/agent-session-at-rest-create.ts @@ -40,7 +40,8 @@ export type AgentSessionAtRestCreateRequest = AgentSessionRecordIdentity & export type AgentSessionAtRestCreateResult = { record: AgentSessionRecord - /** The row a replay answers from; a fresh create's is already settled as succeeded. */ + /** Pending until the create's journal is open and any adopted history imported; a replay of a + * pending row does both again. */ operationRow: AgentSessionOperationRow replayed: boolean } @@ -85,10 +86,7 @@ export function commitAgentSessionAtRestCreate( lease: { ...founded.lease, runtimeFence: existing.lease.runtimeFence } } : founded - const operationRow: AgentSessionOperationRow = { - ...pendingAgentSessionOperationRow({ ...request.operation, now: request.now }), - outcome: { status: 'succeeded', sessionId: request.sessionId } - } + const operationRow = pendingAgentSessionOperationRow({ ...request.operation, now: request.now }) state.records.set(record.sessionId, record) state.operations.set( agentSessionOperationKey(operationRow.callerKey, operationRow.operationId), From e6c256a347e31b7230ef356f2bc7496c6b49093e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:08:41 -0700 Subject: [PATCH 07/84] fix(native-chat): a quit drains a create in flight before it closes conversations The at-rest create ran under the session's serialize but was not tracked with the host's attaches, so a quit could close every open conversation and then let an in-flight create open one behind the teardown. The create is now tracked from enqueue, as the agent start is. --- ...structured-agent-session-create-at-rest.ts | 6 +- ...ent-session-create-drained-at-quit.test.ts | 64 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-create-drained-at-quit.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create-at-rest.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create-at-rest.ts index fe2153e49b22..5eec23700ae8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create-at-rest.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create-at-rest.ts @@ -30,8 +30,10 @@ export function createStructuredAgentSessionAtRest( callerKey: string, params: AgentSessionAttachParams ): Promise { - return context.serialize(params.envelope.sessionId, () => - createAtRest(context, callerKey, params) + // Tracked from enqueue: a quit drains a create before it closes conversations, so none it opens + // is left open behind the teardown. + return context.tasks.trackAttach( + context.serialize(params.envelope.sessionId, () => createAtRest(context, callerKey, params)) ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create-drained-at-quit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create-drained-at-quit.test.ts new file mode 100644 index 000000000000..1c3b5854c81e --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create-drained-at-quit.test.ts @@ -0,0 +1,64 @@ +// A quit drains a create still in flight before it closes conversations, as it drains a start. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams +} from './structured-agent-session-host-test-data' + +let root: string | null = null + +afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }) + } + root = null +}) + +it('closes the conversation a create opened while the quit waited for it', async () => { + root = await mkdtemp(join(tmpdir(), 'orca-create-drained-at-quit-')) + const journalOpening = Promise.withResolvers() + const releaseJournalOpen = Promise.withResolvers() + const adapter: StructuredAgentSessionAdapter = { + acquire: vi.fn(), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn(), + historyFilePath: () => { + journalOpening.resolve() + return releaseJournalOpen.promise + } + } + const host = new StructuredAgentSessionHost({ + store: await openTestAgentSessionRecordStore(root), + adapter, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + now: () => NOW + }) + + const created = host.create({ callerKey: 'client-1' }, hostTestAttachParams(null)) + // Past the record's commit, opening the chat's journal. + await journalOpening.promise + let quitFinished = false + const quit = host.flushAllStreamedEvents().then(() => { + quitFinished = true + }) + await new Promise((resolve) => setImmediate(resolve)) + expect(quitFinished).toBe(false) + + releaseJournalOpen.resolve(null) + expect(await created).toMatchObject({ ok: true }) + await quit + expect(host.collaboratorsForTests().sessions.has(SESSION)).toBe(false) + expect(adapter.acquire).not.toHaveBeenCalled() +}) From c7afc67596af7c7edf3532dd30ce32522b9302c1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:11:38 -0700 Subject: [PATCH 08/84] fix(native-chat): the agentSession.create span measures a chat's first agent start only Moving the start to the first message put the span around every agent start: idle-eviction resumes, start retries, restart continuations and starts an operation asks for. It now wraps only a start of a record no agent has run in Orca yet (its chain empty, or holding only the conversation it adopted), which is the start a create used to run. A failed first start retried is still a first start. --- ...tured-agent-session-adopted-import.test.ts | 4 +- ...ured-agent-session-attach-orchestration.ts | 56 ++++++++----- ...red-agent-session-first-start-span.test.ts | 81 +++++++++++++++++++ 3 files changed, 121 insertions(+), 20 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-first-start-span.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index b45dc173d193..1770c467e000 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -219,7 +219,7 @@ describe('adopting a provider conversation on create', () => { // quit) leaves its operation pending, so its replay imports again rather than answering empty. it.each([ [ - 'its import write', + 'import write', () => vi .spyOn(AgentSessionJournal.prototype, 'replaceEpochItems') @@ -227,7 +227,7 @@ describe('adopting a provider conversation on create', () => { 'disk write failed' ], [ - 'its journal open', + 'journal open', (sessionAdapter: StructuredAgentSessionAdapter) => { sessionAdapter.historyFilePath = vi .fn>() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 233df670024f..b86fe7c55f28 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -35,6 +35,7 @@ import { } from './structured-agent-session-provider-child' import type { DeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { addAgentSessionCreatePhaseAttributes, withAgentSessionCreatePhase, @@ -61,29 +62,48 @@ export function attachStructuredAgentSessionUnderSerialize( params: AgentSessionAttachParams, options: StructuredAgentSessionAttachOptions = {} ): Promise> { + const record = context.deps.store.getRecord(params.envelope.sessionId) // Tracked from the start: a quit drains it before it evicts, so no child is orphaned behind that. return context.tasks.trackAttach( - withAgentSessionSpan(async (span) => { - const startedAtMs = Date.now() - const phases: Parameters[0][] = [] - try { - return await runAttach(context, callerKey, params, { - ...options, - recordPhase: (timing) => { - phases.push(timing) - options.recordPhase?.(timing) - } - }) - } finally { - addAgentSessionCreatePhaseAttributes(span, { - totalDurationMs: Math.max(0, Date.now() - startedAtMs), - phases - }) - } - }) + record && isFirstAgentStart(record) + ? withFirstStartSpan(context, callerKey, params, options) + : runAttach(context, callerKey, params, options) ) } +/** No agent has run this conversation in Orca yet: its chain holds nothing, or only what it adopted. */ +function isFirstAgentStart(record: AgentSessionRecord): boolean { + return record.providerHandleChain.every((link) => link.origin === 'adopted') +} + +/** The `agentSession.create` span measures a chat's first agent start, which its create used to + * run; a later start (after idle, a crash, a retry of a later message) is not in it. */ +function withFirstStartSpan( + context: StructuredAgentSessionAttachContext, + callerKey: string, + params: AgentSessionAttachParams, + options: StructuredAgentSessionAttachOptions +): Promise> { + return withAgentSessionSpan(async (span) => { + const startedAtMs = Date.now() + const phases: Parameters[0][] = [] + try { + return await runAttach(context, callerKey, params, { + ...options, + recordPhase: (timing) => { + phases.push(timing) + options.recordPhase?.(timing) + } + }) + } finally { + addAgentSessionCreatePhaseAttributes(span, { + totalDurationMs: Math.max(0, Date.now() - startedAtMs), + phases + }) + } + }) +} + async function runAttach( context: StructuredAgentSessionAttachContext, callerKey: string, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-first-start-span.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-first-start-span.test.ts new file mode 100644 index 000000000000..d6b03e095da5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-first-start-span.test.ts @@ -0,0 +1,81 @@ +// The `agentSession.create` span keeps its meaning now that a create starts nothing: it measures a +// chat's first agent start, and no later one. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi, type Mock } from 'vitest' +import * as instrumentation from '../../observability/agent-session-instrumentation' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams +} from './structured-agent-session-host-test-data' +import { attachForTests, startAgentForTests } from './structured-agent-session-attach-test-support' + +let root: string +let host: StructuredAgentSessionHost +let acquire: Mock + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-first-start-span-')) + const store = await openTestAgentSessionRecordStore(root) + acquire = vi.fn(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: store.getRecord(SESSION)?.providerHandleChain.length ? 'resumed' : 'created', + mintedAtFence: fence, + observedAt: NOW + } + })) + host = new StructuredAgentSessionHost({ + store, + adapter: { + acquire, + releaseAcquisition: vi.fn(async () => true), + closeSession: vi.fn(async () => true), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + }, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + now: () => NOW + }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) + vi.restoreAllMocks() +}) + +it('wraps the first start, and its retry after a failure, but no start once an agent has run', async () => { + const span = vi.spyOn(instrumentation, 'withAgentSessionSpan') + acquire.mockRejectedValueOnce(new Error('signed out')) + + expect(await host.create({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({ + ok: true + }) + expect(span).not.toHaveBeenCalled() + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: false }) + // Still the chat's first start: no agent has run it. + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: true }) + expect(span).toHaveBeenCalledTimes(2) + + // Put to rest and started again, as the next message after the idle sweep does. + await host.collaboratorsForTests().lifetime.stopAgent(SESSION, 'evict') + expect( + await attachForTests(host, { callerKey: 'client-1' }, hostTestAttachParams(2)) + ).toMatchObject({ ok: true }) + expect(acquire).toHaveBeenCalledTimes(3) + expect(span).toHaveBeenCalledTimes(2) +}) From 8c58a72e2310b0ba9348fdc91d187d2e2470df43 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:16:54 -0700 Subject: [PATCH 09/84] fix(orchestration): a structured worker's preamble waits across a retried start for its 60 s budget The preamble's wait for the worker's agent ended at the first failed start that is retried on its own (an account switch, a lease being settled), so the coordinator read outcome_unknown at once, and a start the 15 s retry cleared never reached ready. The wait now runs through such retries: ready if the agent takes the preamble within the budget, outcome_unknown quoting the start failure if a start is still being retried when the budget runs out, and failed at the preamble when the start is refused for the person to fix. Host-backed tests cover the three outcomes and the reason the dispatch is given. --- ...tructured-agent-session-send-settlement.ts | 18 ++- ...n-structured-worker-preamble-start.test.ts | 140 ++++++++++++++++++ ...orchestration-structured-worker-session.ts | 35 +++-- .../deliver-worker-dispatch-preamble.ts | 40 +++-- 4 files changed, 203 insertions(+), 30 deletions(-) create mode 100644 src/main/runtime/rpc/methods/orchestration-structured-worker-preamble-start.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts index 76b3ba60126c..24251e3a91a7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts @@ -28,11 +28,15 @@ export type SendSettlementWaitOptions = { /** How long to observe; unanswered by then resolves undefined. */ budgetMs?: number until?: SendSettlementPoint + /** A failed start waiting for its next try does not end the wait: only a later start's verdict, + * the message's rejection, or the budget does. */ + throughStartRetries?: boolean } type SendSettlementWaiter = { clientMessageId: string until: SendSettlementPoint + throughStartRetries: boolean resolve: (result: SettledSend | undefined) => void reject: (error: Error) => void timer: ReturnType @@ -50,7 +54,7 @@ const MAX_SEND_SETTLEMENT_WAITERS = 1_024 function settledSend( journal: SendSettlementJournal, clientMessageId: string, - until: SendSettlementPoint, + wait: Pick, submission: AgentJournalSubmission | undefined = journal .submissions() .find((candidate) => candidate.clientMessageId === clientMessageId) @@ -58,8 +62,9 @@ function settledSend( if (!submission) { return 'missing' } + const { until } = wait const waiting = - !isRetryingStructuredAgentSessionStart(submission) && + (wait.throughStartRetries || !isRetryingStructuredAgentSessionStart(submission)) && (until === 'answered' ? submission.dispatchState === 'pending' : isQueuedAgentJournalSubmission(submission) && @@ -92,10 +97,14 @@ export class StructuredAgentSessionSendSettlement { ): Promise => { const { signal } = options const until = options.until ?? 'answered' + const throughStartRetries = options.throughStartRetries === true if (signal?.aborted) { return Promise.reject(abortError(signal)) } - const immediate = settledSend(this.journalFor(sessionId), clientMessageId, until) + const immediate = settledSend(this.journalFor(sessionId), clientMessageId, { + until, + throughStartRetries + }) if (immediate === 'missing') { return Promise.reject(new Error('agent session send disappeared before settlement')) } @@ -113,6 +122,7 @@ export class StructuredAgentSessionSendSettlement { const waiter: SendSettlementWaiter = { clientMessageId, until, + throughStartRetries, resolve, reject, timer: setTimeout(() => { @@ -152,7 +162,7 @@ export class StructuredAgentSessionSendSettlement { const result = settledSend( journal, waiter.clientMessageId, - waiter.until, + waiter, submissions.get(waiter.clientMessageId) ) if (result !== 'pending') { diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-preamble-start.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-preamble-start.test.ts new file mode 100644 index 000000000000..c60b996f6fab --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-preamble-start.test.ts @@ -0,0 +1,140 @@ +// A structured worker's preamble is its first message, which starts its agent. What the dispatch +// is told about that start, read from a real host: ready, failed at the preamble, or unknown while a +// start is still being retried. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' +import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' +import { + AgentSessionAcquisitionRefusal, + AgentSessionPreSpawnError, + type StructuredAgentSessionAdapter +} from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' +import { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams +} from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { mintAgentSessionOperationId } from '../../orchestration/structured-pointer-operation-id' +import { sendStructuredWorkerPreamble } from './orchestration-structured-worker-session' +import { structuredPreambleTurnStart } from './orchestration/worker/deliver-worker-dispatch-preamble' + +let root: string +let host: StructuredAgentSessionHost +let acquire: Mock +let clock: number + +const accountSwitch = () => + new AgentSessionPreSpawnError(new Error('switching'), { reason: 'accountSwitchInProgress' }) + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-worker-preamble-start-')) + clock = Date.now() + const store = await openTestAgentSessionRecordStore(root) + acquire = vi.fn(async ({ fence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: clock, spawnToken }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: clock + } + })) + host = new StructuredAgentSessionHost({ + store, + adapter: { + supportsCreate: () => true, + acquire, + releaseAcquisition: vi.fn(async () => true), + closeSession: vi.fn(async () => true), + dispatch: vi.fn(async ({ clientMessageId }) => ({ + state: 'accepted' as const, + providerIdentity: { + provider: 'codex' as const, + threadId: THREAD, + turnId: `turn-${clientMessageId}`, + ordinal: 1 + } + })), + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(), + setOption: vi.fn() + }, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + now: () => clock + }) + const create = hostTestAttachParams(null) + // On the host's clock: the preamble's own operation ids are minted from it too. + create.envelope.clientOperationId = mintAgentSessionOperationId(clock) + expect(await host.create({ callerKey: 'client-1' }, create)).toMatchObject({ ok: true }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function sendPreamble(budgetMs: number) { + return sendStructuredWorkerPreamble({ + host, + sessionId: SESSION, + dispatchId: 'dispatch-1', + preamble: 'You are a worker.', + budgetMs + }) +} + +describe('a structured worker preamble that starts its agent', () => { + it('is ready once the agent takes it', async () => { + const delivery = await sendPreamble(5_000) + + expect(delivery).toEqual({ state: 'accepted' }) + expect(structuredPreambleTurnStart(delivery)).toEqual({ verdict: 'observed' }) + }) + + it('fails at the preamble when the start is refused for the person to fix', async () => { + acquire.mockRejectedValueOnce( + new AgentSessionAcquisitionRefusal('not signed in', 'notSignedIn') + ) + + await expect(sendPreamble(5_000)).rejects.toMatchObject({ + code: 'dispatch_preamble_undelivered' + }) + }) + + it('keeps waiting across a retried start, and is ready when the retry takes it', async () => { + acquire.mockRejectedValueOnce(accountSwitch()) + const delivery = sendPreamble(5_000) + await vi.waitFor(async () => + expect((await host.journalSnapshot(SESSION)).submissions[0]?.startFailure).toBeDefined() + ) + + // The retry comes due; the delivery loop takes it on its next wake. + clock += 15_000 + host.collaboratorsForTests().conversationDelivery.loop.wake(SESSION) + + expect(await delivery).toEqual({ state: 'accepted' }) + expect(acquire).toHaveBeenCalledTimes(2) + }) + + it('reads unknown, with the start failure, while a start is still retried at the budget', async () => { + acquire.mockRejectedValue(accountSwitch()) + + const delivery = await sendPreamble(200) + + expect(delivery).toMatchObject({ state: 'pending', startFailure: { attempts: 1 } }) + expect(structuredPreambleTurnStart(delivery)).toEqual({ + verdict: 'unobserved', + reason: expect.stringMatching( + /^The worker's agent did not start: .+ The dispatch preamble waits for its next start;/ + ) + }) + expect(acquire).toHaveBeenCalledOnce() + }) +}) 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 f31999cb3b61..adaadb9c30a3 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -39,6 +39,7 @@ import { } from '../../structured-worker-identity' import { createKeyedTrailingEdgeCoalescer } from '../../keyed-trailing-edge-coalescer' import { createStructuredAgentSessionForWorktree } from './structured-agent-session-create' +import type { SendSettlementWaitOptions } from '../../../native-chat/agent-session-wire/structured-agent-session-send-settlement' type StructuredWorkerBinding = { sessionId: string @@ -218,7 +219,7 @@ type StructuredWorkerPreambleHost = Pick< /** What became of the preamble within the wait. `pending`: the worker's agent had not taken it; the * host still holds it for that agent and never re-sends it. `startFailure`: its start failed and - * another is booked, which is why it had not. */ + * another was still booked when the wait ran out, which is why it had not. */ export type StructuredWorkerPreambleDelivery = | { state: 'accepted' } | { state: 'pending'; startFailure?: AgentJournalStartFailure } @@ -229,6 +230,7 @@ export async function sendStructuredWorkerPreamble(args: { sessionId: string dispatchId: string preamble: string + budgetMs?: number }): Promise { const body: AgentJournalMessageItem = { kind: 'message', @@ -258,15 +260,7 @@ export async function sendStructuredWorkerPreamble(args: { const answered = agentSessionSendSubmission(result.value) const submission = answered?.dispatchState === 'pending' - ? (agentSessionSendSubmission( - ( - await args.host - .waitForSendSettlement(args.sessionId, result.value.clientMessageId, { - budgetMs: ORCHESTRATION_READINESS_TIMEOUT_MS - }) - .catch(() => undefined) - )?.value - ) ?? answered) + ? ((await preambleVerdict(args, result.value.clientMessageId)) ?? answered) : answered if (submission?.dispatchState === 'accepted') { return { state: 'accepted' } @@ -300,6 +294,27 @@ export async function sendStructuredWorkerPreamble(args: { ) } +/** The preamble's verdict within the budget: a start that fails and is tried again inside it is + * still waited on. At the budget, a start still waiting for its next try answers with its failure. */ +async function preambleVerdict( + args: { host: StructuredWorkerPreambleHost; sessionId: string; budgetMs?: number }, + clientMessageId: string +) { + const read = (options: SendSettlementWaitOptions) => + args.host + .waitForSendSettlement(args.sessionId, clientMessageId, options) + .then((settled) => agentSessionSendSubmission(settled?.value)) + .catch(() => undefined) + return ( + (await read({ + budgetMs: args.budgetMs ?? ORCHESTRATION_READINESS_TIMEOUT_MS, + throughStartRetries: true + })) ?? + // A zero budget answers at once only for a start waiting for its next try. + (await read({ budgetMs: 0 })) + ) +} + function requireInstalledHost(): StructuredAgentSessionHost { const host = getStructuredAgentSessionHost() if (!host) { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts index a18a90080b56..2e66958af737 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts @@ -4,7 +4,10 @@ import { buildDispatchPreamble, dispatchPreambleSendOptions } from '../../../../orchestration/preamble' -import { sendStructuredWorkerPreamble } from '../../orchestration-structured-worker-session' +import { + sendStructuredWorkerPreamble, + type StructuredWorkerPreambleDelivery +} from '../../orchestration-structured-worker-session' import type { WorkerTurnStartObservation } from './worker-start-turn-observation' import type { createStructuredWorkerSessionForWorktree } from './worker-topology' @@ -54,21 +57,7 @@ export async function deliverWorkerDispatchPreamble(args: { dispatchId: args.dispatchId, preamble }) - return { - structuredTurnStart: - delivery.state === 'accepted' - ? { verdict: 'observed' } - : { - verdict: 'unobserved', - reason: delivery.startFailure - ? `The worker's agent did not start: ${delivery.startFailure.reason} The dispatch ` + - 'preamble waits for its next start; if the worker then reports, this Dispatch ' + - 'settles normally.' - : 'The dispatch preamble was accepted, but the agent had not started to take it. ' + - 'It is delivered when the agent starts; if the worker then reports, this ' + - 'Dispatch settles normally.' - } - } + return { structuredTurnStart: structuredPreambleTurnStart(delivery) } } return { prompt: ( @@ -80,3 +69,22 @@ export async function deliverWorkerDispatchPreamble(args: { ).prompt } } + +/** What a structured preamble's delivery says about the worker's turn start. */ +export function structuredPreambleTurnStart( + delivery: StructuredWorkerPreambleDelivery +): WorkerTurnStartObservation { + if (delivery.state === 'accepted') { + return { verdict: 'observed' } + } + return { + verdict: 'unobserved', + reason: delivery.startFailure + ? `The worker's agent did not start: ${delivery.startFailure.reason} The dispatch ` + + 'preamble waits for its next start; if the worker then reports, this Dispatch ' + + 'settles normally.' + : 'The dispatch preamble was accepted, but the agent had not started to take it. ' + + 'It is delivered when the agent starts; if the worker then reports, this ' + + 'Dispatch settles normally.' + } +} From 42f770be590d1c9a19d474e0ab28ae0de66c11f9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:24:57 -0700 Subject: [PATCH 10/84] fix(native-chat): a launch prompt counts as delivered only once the host hands it to the agent A new chat now starts its agent on its first message, and the host answers the send when it accepts the message, before that start. A launch prompt counted as delivered on that answer, so "Resolve comments with AI" posted its GitHub fixing replies and resolved threads, the checks and fix actions toasted "Started an AI agent", and review notes and browser annotations were cleared, all for an agent whose start then failed (signed out, not installed, exiting as it started). The launch prompt now reads the session's own publication until the host hands the message over (delivered) or rejects it (not delivered: the claimed comments, notes and annotations are handed back for a retry). A start the host is still retrying keeps the wait open; the retry schedule ends in a verdict, and a 45-minute cap sits past the longest one. No new wire field: a host that predates the hand-over record answers a send only once it is handed over, and that answer counts. --- ...omment-resolution-launch-delivery.test.tsx | 223 ++++++++++++++++++ ...ed-agent-session-launch-prompt-handover.ts | 84 +++++++ ...ctured-agent-session-launch-prompt.test.ts | 163 +++++++++++-- .../structured-agent-session-launch-prompt.ts | 27 ++- 4 files changed, 472 insertions(+), 25 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx create mode 100644 src/renderer/src/lib/structured-agent-session-launch-prompt-handover.ts diff --git a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx new file mode 100644 index 000000000000..224867c00aa2 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx @@ -0,0 +1,223 @@ +// @vitest-environment happy-dom + +// "Resolve comments with AI" writes to GitHub (fixing replies, resolved threads) only once the +// launch prompt reaches the agent. The chat's first message starts that agent, so a start that +// fails must post nothing and hand the comments back for a retry. + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + call: vi.fn(), + subscribe: vi.fn(), + launchAgentInNewTab: vi.fn(), + toastError: vi.fn(), + toastSuccess: vi.fn() +})) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.call, + subscribeStructuredAgentSession: mocks.subscribe +})) +vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab: mocks.launchAgentInNewTab })) +vi.mock('@/lib/focus-terminal-tab-surface', () => ({ focusTerminalTabSurface: vi.fn() })) +vi.mock('sonner', () => ({ toast: { error: mocks.toastError, success: mocks.toastSuccess } })) + +import type { AgentJournalSubmission } from '../../../../../shared/agent-session-journal-types' +import type { PRComment } from '../../../../../shared/github/comment-types' +import { enqueueStructuredAgentSessionLaunchPrompt } from '@/components/native-chat/structured-agent-session-outbox-storage' +import { settleStructuredAgentLaunchPrompt } from '@/lib/structured-agent-session-launch-prompt' +import { + clearPendingPRCommentAiAck, + type PendingPRCommentAiAck +} from '../pr-comments-ai-launch-ack' +import { runSourceControlAgentActionStart } from '../runSourceControlAgentActionStart' +import { useChecksPanelAiAcknowledgement } from './use-checks-panel-ai-acknowledgement' + +const REVIEW_KEY = 'repo-1::42::sha-1' +const PENDING = { + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending' as const, + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null, + handoverRecorded: true +} + +function comment(overrides: Partial): PRComment { + return { + id: 1, + author: 'alice', + authorAvatarUrl: '', + body: 'Please update this.', + createdAt: '2026-05-14T00:00:00Z', + url: 'https://github.com/acme/widgets/pull/42#discussion_r1', + ...overrides + } +} + +/** An open review thread and a conversation comment: once the prompt is delivered, the thread is + * resolved and the conversation comment gets one fixing reply. */ +function resolution(): PendingPRCommentAiAck { + const target = { repoPath: '/repos/widgets', repoId: 'repo-1', prNumber: 42 } + return { + reviewContextKey: REVIEW_KEY, + provider: 'github', + selectedGroups: [ + { + kind: 'thread', + threadId: 'T1', + root: comment({ id: 10, threadId: 'T1', path: 'src/a.ts', isResolved: false }), + replies: [] + }, + { + kind: 'standalone', + comment: comment({ id: 20, url: 'https://github.com/acme/widgets/pull/42#issuecomment-9' }) + } + ], + githubTarget: { ...target, prRepo: { owner: 'acme', name: 'widgets' } }, + githubResolveTarget: target + } +} + +function acknowledgement() { + const model = { + addPRConversationComment: vi.fn(async () => ({ + ok: true as const, + comment: comment({ id: 30 }) + })), + addPRReviewCommentReply: vi.fn(async () => ({ + ok: true as const, + comment: comment({ id: 31, threadId: 'T1' }) + })), + resolveReviewThread: vi.fn(async () => true), + asyncResultKeyRef: { current: REVIEW_KEY }, + claimedCommentResolutionRef: { current: null as PendingPRCommentAiAck | null }, + commentsRef: { current: [] as PRComment[] }, + commentsSelectionClearTokenRef: { current: 0 }, + pendingCommentResolutionRef: { current: resolution() as PendingPRCommentAiAck | null }, + commentResolutionLaunchAcceptedRef: { current: false }, + setCommentResolutionAckBusyNow: vi.fn(), + setComments: vi.fn(), + setCommentsSelectionClearRequest: vi.fn(), + settings: null, + fetchComments: vi.fn(async () => {}), + fetchGitLabDetails: vi.fn(async () => {}) + } + const hook = renderHook(() => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the model carries every member the hook reads; its state setters are no-op spies. + useChecksPanelAiAcknowledgement( + model as unknown as Parameters[0] + ) + ) + return { model, hook } +} + +/** The new chat's first message, as the host answers it (accepted) and then publishes it. */ +function hostAnswers(clientMessageId: string, final: Partial): void { + const submission = { clientMessageId, ...PENDING } + mocks.call.mockResolvedValue({ + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value: { clientMessageId, submission } + }) + mocks.subscribe.mockImplementation(async (_target, _params, onEvent) => { + queueMicrotask(() => + onEvent({ + type: 'batch', + sessionId: 'session-1', + batch: { + cursor: { epoch: 'epoch-1', sequence: 3 }, + items: [], + removedItemIds: [], + submissions: [{ ...submission, ...final }] + } + }) + ) + return { unsubscribe: vi.fn() } + }) +} + +async function resolveCommentsWithAi( + hook: ReturnType['hook'], + firstMessageEndsAs: Partial +) { + const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'Fix the comments') + hostAnswers(stagedEntry!.clientMessageId, firstMessageEndsAs) + mocks.launchAgentInNewTab.mockImplementation(() => ({ + surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'session-1' }, + promptDeliveryResult: settleStructuredAgentLaunchPrompt({ + launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), + options: { prompt: 'Fix the comments', promptDelivery: 'submit-after-ready' }, + stagedEntry + }) + })) + return act(() => + runSourceControlAgentActionStart({ + selectedAgent: 'claude', + trimmedCommandInput: 'Fix the comments', + agentArgs: '', + agentArgsApply: false, + commandTemplate: '{basePrompt}', + saveTargetValue: 'none', + actionId: 'resolveComments', + repoId: null, + settings: null, + repo: null, + worktreeId: 'wt-1', + groupId: 'wt-1', + promptDelivery: 'submit-after-ready', + launchPlatform: 'linux', + launchSource: 'task_page', + onStart: undefined, + onLaunchAccepted: hook.result.current.handleLaunchAccepted, + onLaunchAborted: hook.result.current.handleLaunchAborted, + onLaunched: () => + hook.result.current.consumeClaimedCommentResolutionAfterDeliveryRef.current(), + onClose: vi.fn() + }) + ) +} + +describe('Resolve comments with AI, when the chat starts on its first message', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + clearPendingPRCommentAiAck() + }) + afterEach(() => { + cleanup() + }) + + it.each(['notSignedIn', 'providerMissing', 'providerExited'])( + 'posts no reply and resolves no thread when the start fails (%s), and hands the comments back', + async (kind) => { + const { model, hook } = acknowledgement() + await expect( + resolveCommentsWithAi(hook, { dispatchState: 'rejected', rejection: { kind } }) + ).resolves.toBe(false) + + expect(model.addPRReviewCommentReply).not.toHaveBeenCalled() + expect(model.addPRConversationComment).not.toHaveBeenCalled() + expect(model.resolveReviewThread).not.toHaveBeenCalled() + expect(mocks.toastSuccess).not.toHaveBeenCalled() + // Handed back for a retry, not consumed. + expect(model.pendingCommentResolutionRef.current).toMatchObject({ + reviewContextKey: REVIEW_KEY + }) + } + ) + + it('posts its reply and resolves its thread exactly once when the agent takes the prompt', async () => { + const { model, hook } = acknowledgement() + await expect(resolveCommentsWithAi(hook, { handedOverAt: 5 })).resolves.toBe(true) + + await vi.waitFor(() => expect(model.resolveReviewThread).toHaveBeenCalledTimes(1)) + expect(model.addPRConversationComment).toHaveBeenCalledTimes(1) + expect(model.pendingCommentResolutionRef.current).toBeNull() + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt-handover.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt-handover.ts new file mode 100644 index 000000000000..b759d04359f5 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt-handover.ts @@ -0,0 +1,84 @@ +// When a launch prompt counts as delivered: once the host hands it to the agent. A chat's first +// message starts its agent, and the host answers the send when it accepts the message, before that +// start; a caller that writes outward on delivery (GitHub replies, cleared notes) must not do so for +// an agent that never started. Read from the session's own publication, so an older host needs no +// new field: one that predates the hand-over record answers a send only once it is handed over. + +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' +import { subscribeStructuredAgentSession } from '@/runtime/structured-agent-session-client' + +/** + * Past the longest a start can take to reach a verdict: three retried refusals wait 15 s, 60 s and + * 5 min, and a start that never finishes is stopped by the host's idle sweep within 35 min. + */ +export const STRUCTURED_LAUNCH_PROMPT_HANDOVER_CAP_MS = 45 * 60_000 + +type Handover = 'handed-over' | 'not-delivered' | 'waiting' + +/** `waiting` covers a start the host is still retrying: the message stays queued meanwhile. */ +export function structuredLaunchPromptHandover( + submission: Pick +): Handover { + if (submission.dispatchState === 'accepted') { + return 'handed-over' + } + if (submission.dispatchState === 'pending') { + return isQueuedAgentJournalSubmission(submission) ? 'waiting' : 'handed-over' + } + // Rejected is a start that failed for good; unknown is a hand-over nobody can vouch for. + return 'not-delivered' +} + +/** Whether the host handed the launch prompt to the chat's agent. Anything that ends the read + * first (the stream failing, the cap) answers no: the caller keeps what it would have written. */ +export function awaitStructuredLaunchPromptHandover( + sessionId: string, + clientMessageId: string, + capMs = STRUCTURED_LAUNCH_PROMPT_HANDOVER_CAP_MS +): Promise { + return new Promise((resolve) => { + let settled = false + let unsubscribe: (() => void) | null = null + const finish = (delivered: boolean): void => { + if (settled) { + return + } + settled = true + clearTimeout(timer) + unsubscribe?.() + resolve(delivered) + } + const timer = setTimeout(() => finish(false), capMs) + const read = (submissions: readonly AgentJournalSubmission[]): void => { + const submission = submissions.find((entry) => entry.clientMessageId === clientMessageId) + const handover = submission ? structuredLaunchPromptHandover(submission) : 'waiting' + if (handover !== 'waiting') { + finish(handover === 'handed-over') + } + } + subscribeStructuredAgentSession( + { kind: 'local' }, + { sessionId }, + (event) => { + if (event.type === 'snapshot' || event.type === 'reset') { + read(event.page.submissions) + } else if (event.type === 'batch') { + read(event.batch.submissions) + } else { + finish(false) + } + }, + () => finish(false), + () => finish(false) + ).then( + (handle) => { + unsubscribe = handle.unsubscribe + if (settled) { + handle.unsubscribe() + } + }, + () => finish(false) + ) + }) +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts index 6945c0456454..132d4600c53e 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts @@ -6,14 +6,78 @@ import { mutateStructuredAgentSessionLaunchPrompt } from '@/components/native-chat/structured-agent-session-outbox-storage' -const mocks = vi.hoisted(() => ({ call: vi.fn() })) +const mocks = vi.hoisted(() => ({ call: vi.fn(), subscribe: vi.fn(), unsubscribe: vi.fn() })) vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: mocks.call + callStructuredAgentSession: mocks.call, + subscribeStructuredAgentSession: mocks.subscribe })) +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' import { settleStructuredAgentLaunchPrompt } from './structured-agent-session-launch-prompt' +const PENDING = { + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending' as const, + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null +} + +/** The host's answer at acceptance; a host that predates the hand-over record omits it. */ +function sendAnswer(clientMessageId: string, handoverRecorded = true) { + return { + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value: { + clientMessageId, + submission: { clientMessageId, ...PENDING, ...(handoverRecorded ? { handoverRecorded } : {}) } + } + } +} + +function frame( + type: 'snapshot' | 'batch', + submission: AgentJournalSubmission +): AgentSessionSubscribeEvent { + const cursor = { epoch: 'epoch-1', sequence: 2 } + return type === 'batch' + ? { + type, + sessionId: 'session-1', + batch: { cursor, items: [], removedItemIds: [], submissions: [submission] } + } + : { + type, + sessionId: 'session-1', + fence: 2, + page: { + sessionId: 'session-1', + epoch: 'epoch-1', + direction: 'tail', + items: [], + removedItemIds: [], + submissions: [submission], + window: { oldest: null, newest: null, nextCursor: cursor }, + hasOlder: false, + hasNewer: false + } + } +} + +/** The session's publication after the send, frame by frame, as the host would stream it. */ +function publishes(...events: AgentSessionSubscribeEvent[]): void { + mocks.subscribe.mockImplementation(async (_target, _params, onEvent) => { + queueMicrotask(() => events.forEach((event) => onEvent(event))) + return { unsubscribe: mocks.unsubscribe } + }) +} + describe('settleStructuredAgentLaunchPrompt', () => { beforeEach(() => { vi.clearAllMocks() @@ -23,28 +87,15 @@ describe('settleStructuredAgentLaunchPrompt', () => { ) }) - it('reports an admitted launch prompt delivered while retaining it for the provider echo', async () => { + it('reports a launch prompt delivered once the host hands it over, retaining it for the provider echo', async () => { const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'review this') const onPromptDelivered = vi.fn() - mocks.call.mockResolvedValue({ - ok: true, - replayed: false, - fence: 1, - cursor: { epoch: 'epoch-1', sequence: 1 }, - value: { - clientMessageId: stagedEntry!.clientMessageId, - submission: { - clientMessageId: stagedEntry!.clientMessageId, - fence: 1, - payloadFingerprint: 'fingerprint', - dispatchState: 'pending', - providerItemId: null, - reason: null, - submittedAt: 1, - resolvedAt: null - } - } - }) + const clientMessageId = stagedEntry!.clientMessageId + mocks.call.mockResolvedValue(sendAnswer(clientMessageId)) + publishes( + frame('snapshot', { clientMessageId, ...PENDING, handoverRecorded: true }), + frame('batch', { clientMessageId, ...PENDING, handoverRecorded: true, handedOverAt: 5 }) + ) await expect( settleStructuredAgentLaunchPrompt({ @@ -88,4 +139,72 @@ describe('settleStructuredAgentLaunchPrompt', () => { expect.objectContaining({ lastFailure: expect.anything() }) ) }) + + // Whatever the start's failure (signed out, not installed, the agent exiting as it started), it + // ends as the message's rejection; nothing that writes on delivery may run. + it.each(['notSignedIn', 'providerMissing', 'providerExited'])( + 'reports a launch prompt whose start failed (%s) undelivered, after a retried start', + async (kind) => { + const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'review this') + const onPromptDelivered = vi.fn() + const clientMessageId = stagedEntry!.clientMessageId + mocks.call.mockResolvedValue(sendAnswer(clientMessageId)) + const queued = { clientMessageId, ...PENDING, handoverRecorded: true } + publishes( + frame('snapshot', queued), + frame('batch', { + ...queued, + startFailure: { + attempts: 1, + reason: 'An account switch is in progress.', + rejection: { kind: 'accountSwitchInProgress' }, + failedAt: 2, + nextAttemptAt: 15_002 + } + }), + frame('batch', { ...queued, dispatchState: 'rejected', rejection: { kind } }) + ) + + await expect( + settleStructuredAgentLaunchPrompt({ + launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), + options: { prompt: 'review this', onPromptDelivered }, + stagedEntry + }) + ).resolves.toEqual({ delivered: false, failureNotified: false }) + expect(onPromptDelivered).not.toHaveBeenCalled() + expect(mocks.unsubscribe).toHaveBeenCalledOnce() + } + ) + + it('counts a pending answer from a host that predates the hand-over record as handed over', async () => { + const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'review this') + mocks.call.mockResolvedValue(sendAnswer(stagedEntry!.clientMessageId, false)) + + await expect( + settleStructuredAgentLaunchPrompt({ + launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), + options: { prompt: 'review this' }, + stagedEntry + }) + ).resolves.toEqual({ delivered: true, failureNotified: false }) + expect(mocks.subscribe).not.toHaveBeenCalled() + }) + + it('reports undelivered when the session stream fails before a verdict', async () => { + const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'review this') + mocks.call.mockResolvedValue(sendAnswer(stagedEntry!.clientMessageId)) + mocks.subscribe.mockImplementation(async (_target, _params, _onEvent, onError) => { + queueMicrotask(() => onError(new Error('stream closed'))) + return { unsubscribe: mocks.unsubscribe } + }) + + await expect( + settleStructuredAgentLaunchPrompt({ + launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), + options: { prompt: 'review this' }, + stagedEntry + }) + ).resolves.toEqual({ delivered: false, failureNotified: false }) + }) }) diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index 0caaafbc6f40..46e21bc9bc9b 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -2,6 +2,7 @@ import type { AgentSessionMutationResult, AgentSessionSendResult } from '../../../shared/agent-session-wire' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { requeueStructuredAgentSessionSendRefusal, stageStructuredAgentSessionOutboxEntryForSend, @@ -16,6 +17,10 @@ import { } from '@/components/native-chat/structured-agent-session-outbox-storage' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { createBrowserUuid } from '@/lib/browser-uuid' +import { + awaitStructuredLaunchPromptHandover, + structuredLaunchPromptHandover +} from '@/lib/structured-agent-session-launch-prompt-handover' export type StructuredPromptDeliveryResult = { delivered: boolean @@ -95,9 +100,13 @@ function mutateEntry( ) } +/** How the host first answered an admitted launch prompt: the message, or held as a queued draft. */ +type LaunchPromptAnswer = AgentJournalSubmission | 'queued' + async function dispatchStructuredLaunchPrompt( entry: StructuredAgentSessionOutboxEntry, - receipt: LaunchReceipt + receipt: LaunchReceipt, + onAnswer: (answer: LaunchPromptAnswer) => void ): Promise { // Why: an unsaved stage must leave the entry queued; a held 'dispatching' copy is never drained. if ( @@ -131,8 +140,10 @@ async function dispatchStructuredLaunchPrompt( if ('queued' in result.value) { // The host holds the draft; the outbox entry is spent. mutateEntry(entry, () => null) + onAnswer('queued') return true } + onAnswer(result.value.submission) const dispatchState = result.value.submission.dispatchState mutateEntry(entry, (current) => dispatchState === 'accepted' @@ -169,13 +180,23 @@ export function settleStructuredAgentLaunchPrompt(args: { return { delivered: false, failureNotified: true } } const entry = args.stagedEntry + let answer: LaunchPromptAnswer | undefined const dispatch = shareStructuredAgentLaunchPromptDispatch( entry.sessionId, entry.clientMessageId, receipt.fence, - () => dispatchStructuredLaunchPrompt(entry, receipt) + () => + dispatchStructuredLaunchPrompt(entry, receipt, (answered) => { + answer = answered + }) ) - const delivered = await dispatch.promise + // Admitted is not delivered: the chat's first message starts its agent, which may fail. A + // queued draft keeps its old meaning; a dispatch the chat's own outbox ran answered elsewhere. + const delivered = + (await dispatch.promise) && + (answer === 'queued' || + (answer !== undefined && structuredLaunchPromptHandover(answer) === 'handed-over') || + (await awaitStructuredLaunchPromptHandover(entry.sessionId, entry.clientMessageId))) if (delivered) { args.options.onPromptDelivered?.() } From a4b3dd29204f6343b01eafd186407ac783667990 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:29:30 -0700 Subject: [PATCH 11/84] test(native-chat): a create over agentSession.* stays at rest on replay, resumes what it adopted, and recovers an older host's failed start Covers the plan items the first round found missing: a plain create replayed under its own operation id starts nothing and its first send starts the agent; agentSession.ensure answers method_not_found; an adopting create's first message resumes the adopted Codex thread rather than starting one; and a chat whose create-time start an older host lost is created again under a new operation and starts on its next message. --- ...d-agent-session-integration-create.test.ts | 177 ++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 src/main/runtime/structured-agent-session-integration-create.test.ts diff --git a/src/main/runtime/structured-agent-session-integration-create.test.ts b/src/main/runtime/structured-agent-session-integration-create.test.ts new file mode 100644 index 000000000000..1382ac861964 --- /dev/null +++ b/src/main/runtime/structured-agent-session-integration-create.test.ts @@ -0,0 +1,177 @@ +// A chat's create, driven over `agentSession.*` with everything but the Codex child real: it +// founds the chat at rest, its replay starts nothing, its first message starts the agent (resuming +// an adopted conversation), and a chat an older host failed to start can be created again. + +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import { + attachFingerprintFields, + type AgentSessionAttachParams +} from '../native-chat/agent-session-wire/structured-agent-session-attach' +import { ensureStructuredAgentSessionHost } from './structured-agent-session-runtime' +import { + openStructuredCodexRpcHarness, + SESSION, + type StructuredCodexRpcHarness +} from './structured-codex-session-rpc-test-harness' + +const ADOPTED_THREAD = 'thread-adopted-earlier' + +let harness: StructuredCodexRpcHarness + +beforeEach(async () => { + harness = await openStructuredCodexRpcHarness() +}) + +afterEach(async () => { + await harness.dispose() +}) + +async function send(fence: number, text: string) { + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } + return harness.ok('agentSession.send', { + envelope: harness.envelope('agentSession.send', { body }, fence), + body + }) +} + +function startedTurn(): Promise { + return vi.waitFor(() => + expect(harness.codex.live().calls).toContainEqual( + expect.objectContaining({ method: 'turn/start' }) + ) + ) +} + +/** The intent the RPC resolver builds, with the adoption it resolves for a "resume" create. */ +function adoptingCreate(transcriptPath: string): AgentSessionAttachParams { + const params: AgentSessionAttachParams = { + envelope: { + ...harness.envelope('agentSession.create', {}, null), + payloadFingerprint: '' + }, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'codex', + agent: 'codex', + accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' }, + runtimeKind: 'native', + adopt: { providerHandle: { kind: 'codex', threadId: ADOPTED_THREAD }, transcriptPath } + } + params.envelope.payloadFingerprint = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields(params) + }) + return params +} + +describe('creating a structured codex chat over agentSession.*', () => { + it('stays at rest when the same create is replayed, and its first send starts the agent', async () => { + const params = harness.createIntentParams() + const created = await harness.ok<{ fence: number }>('agentSession.create', params) + const replay = await harness.call('agentSession.create', params) + + expect(replay).toMatchObject({ ok: true, result: { ok: true, replayed: true, fence: 1 } }) + expect(harness.codex.connections).toHaveLength(0) + + await send(created.fence, 'go') + await startedTurn() + expect(harness.codex.connections).toHaveLength(1) + }) + + it('answers agentSession.ensure as a method that no longer exists', async () => { + expect(await harness.call('agentSession.ensure', harness.createIntentParams())).toMatchObject({ + ok: false, + error: { code: 'method_not_found' } + }) + }) + + it('resumes the adopted conversation when its first message starts the agent', async () => { + const transcriptPath = join(harness.root, 'adopted-rollout.jsonl') + const rollout = [ + { type: 'session_meta', payload: { id: ADOPTED_THREAD, cwd: '/repos/workspace-1' } }, + { + type: 'response_item', + timestamp: '2026-09-06T18:00:01.000Z', + payload: { type: 'message', role: 'user', content: 'from before' } + } + ] + await writeFile(transcriptPath, `${rollout.map((line) => JSON.stringify(line)).join('\n')}\n`) + const host = await ensureStructuredAgentSessionHost(harness.hostConfig()) + const created = await host.create({ callerKey: 'client-1' }, adoptingCreate(transcriptPath)) + expect(created).toMatchObject({ ok: true, fence: 1 }) + expect(harness.codex.connections).toHaveLength(0) + + await send(1, 'carry on') + await startedTurn() + + expect(harness.codex.live().resumedThreadId).toBe(ADOPTED_THREAD) + expect(harness.codex.live().calls).not.toContainEqual( + expect.objectContaining({ method: 'thread/start' }) + ) + }) + + it('creates again a chat whose start an older host ran at create and lost', async () => { + // What an older host left: the create reserved the record, its start failed and was proven + // gone, and the renderer kept the chat as a failed launch it relaunches under a new operation. + const host = await ensureStructuredAgentSessionHost(harness.hostConfig()) + const { store } = host.deps + const now = Date.now() + const location = { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' as const + } + const identity = { + sessionId: SESSION, + location, + provider: 'codex' as const, + accountHome: { variable: 'CODEX_HOME' as const, path: '/home/dev/.codex' } + } + const oldCreate = { callerKey: 'client-1', operationId: `${now}-${'a'.repeat(32)}` } + await store.createAtRest({ + ...identity, + claimKeyId: 'key-1', + operation: { ...oldCreate, fingerprint: 'old-create' }, + now + }) + const reserved = await store.reserveOwner({ + ...identity, + expectedFence: 1, + spawnToken: 'spawn-old', + claimKeyId: 'key-1', + handoffOperationId: oldCreate.operationId, + probe: { outcome: 'reservation-unused' }, + operation: { ...oldCreate, fingerprint: 'old-create' }, + now + }) + await store.settleFailedAcquisition({ + sessionId: SESSION, + fence: reserved.record.lease.runtimeFence, + spawnToken: 'spawn-old', + ...oldCreate, + outcome: { status: 'failed', code: 'agent_session_operation_invalid', message: 'signed out' }, + exitProof: 'exit-proven', + now + }) + const left = store.getRecord(SESSION) + expect(left?.providerHandleChain).toEqual([]) + + const created = await harness.ok<{ fence: number }>( + 'agentSession.create', + harness.createIntentParams() + ) + // Founded again at rest, at the fence the failed start left: a fence never moves back. + expect(created.fence).toBe(left?.lease.runtimeFence) + await send(created.fence, 'signed in now') + await startedTurn() + }) +}) From 3ece387539903da387fe61c26c30229acbcae226 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:38:16 -0700 Subject: [PATCH 12/84] test(native-chat): exact fences after a restart, and tab tests that test what they say The Codex reconnect pins the fence at each step again (the first start moves the create's fence 1 to 2; the start after a close moves it one past the released fence) and checks the journal survived the dead child. The cross-version stale-fence send is checked against the fence the restarted record held before the send, not a value read after it. The AI Vault ownership setup starts the chat's agent after its create, since a chat at rest owns no thread yet (this failed in the suite once it could run). The tab-table test for a create that stopped before publishing its tab goes through host.create, and the failed-start one now sends the first message and checks that its rejection leaves the tab in place. --- .../methods/structured-chat-tab-table.test.ts | 22 ++++++++++++++----- ...ructured-agent-session-integration.test.ts | 14 +++++++++--- ...ss-version-agent-session-wire.unit.test.ts | 14 +++++++----- 3 files changed, 37 insertions(+), 13 deletions(-) diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts index 187e3192636e..bbf8c0f09060 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts @@ -38,7 +38,6 @@ import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { commitStructuredAgentSessionCreate } from './structured-agent-session-create' import { closeStructuredAgentSessionChild } from '../../structured-agent-session-close' import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' -import { attachForTests } from '../../../native-chat/agent-session-wire/structured-agent-session-attach-test-support' const WORKTREE = `id:${HOST_TEST_LOCATION.workspaceId}` const SOURCE_TAB = `structured-agent-session-${HOST_TEST_SESSION}` @@ -486,8 +485,8 @@ describe('a create that reserves its tab', () => { }) it('restores no tab for a reserved create that stopped before its tab was published', async () => { - const attached = await attachForTests( - host, + // The host's create committed; the tab it reserved was never published. + const attached = await host.create( caller, hostTestAttachParams(null, { envelope: { @@ -510,13 +509,26 @@ describe('a create that reserves its tab', () => { }) }) - // A create starts no agent, so a start that would fail cannot cost the chat its tab. - it('gives the chat its tab even when its agent could not start', async () => { + // A create starts no agent; the first message does, and its failure is that message's. + it('keeps the chat its tab when its first message cannot start the agent', async () => { acquireFails = true expect(await createChat(HOST_TEST_SESSION, 'reserved-tab')).toMatchObject({ ok: true, value: { tabId: 'reserved-tab' } }) + const body = hostTestMessage('first') + expect( + await host.send(caller, { + envelope: envelopeFor('agentSession.send', HOST_TEST_SESSION, { body }), + body + }) + ).toMatchObject({ ok: true }) + await vi.waitFor(async () => + expect((await host.journalSnapshot(HOST_TEST_SESSION)).submissions[0]).toMatchObject({ + dispatchState: 'rejected' + }) + ) + expect(store.getSessionTabId(HOST_TEST_SESSION)).toBe('reserved-tab') expect(await createChat('session-bravo', 'reserved-tab')).toMatchObject({ ok: false }) }) diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index ad363e1dedba..fd9a4295a257 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -703,13 +703,21 @@ describe('a structured codex session over agentSession.*', () => { // The other half of reconnect: a closed chat's next send starts a replacement that resumes the // thread this session proved rather than forking a new one, at an advanced fence. const reaped = codex.live() + const leaseFence = () => + getStructuredAgentSessionHost()?.deps.store.getRecord(SESSION)?.lease.runtimeFence + // The create founded fence 1 and the first send's start moved it to 2. + expect(leaseFence()).toBe(fence + 1) await ok('agentSession.close', { sessionId: SESSION }) expect(reaped.closed).toBe(true) + const closedFence = leaseFence() ?? 0 + // Still the create's fence: each write names its target, so a client's fence never goes stale. await sendToStart(fence, 'again') - expect( - getStructuredAgentSessionHost()?.deps.store.getRecord(SESSION)?.lease.runtimeFence - ).toBeGreaterThan(fence) + expect(leaseFence()).toBe(closedFence + 1) expect(codex.live().resumedThreadId).toBe(THREAD) + // The journal belongs to the session, not to the process that just died. + expect( + (await getStructuredAgentSessionHost()!.journalSnapshot(SESSION)).items.map(textOf) + ).toContain('Two files.') expect(await call('agentSession.options', { sessionId: SESSION })).toMatchObject({ ok: true, result: { diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index be8288fc0626..5aebc168f6fe 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -18,6 +18,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vite import type { StructuredAgentSessionAdapter } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter' import { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host' import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' +import { startAgentForTests } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-support' import type { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store-test-harness' import { RuntimeSubscriptionRegistry } from '../../../src/main/runtime/runtime-subscription-registry' @@ -504,8 +505,10 @@ describe('cross-version structured agent sessions', () => { }) setStructuredAgentSessionHost(host) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: attachParams builds the wire shape the suite feeds every build; the host takes its typed form. - const attached = await host.create({ callerKey: 'test' }, attachParams(null) as never) - expect(attached.ok).toBe(true) + const created = await host.create({ callerKey: 'test' }, attachParams(null) as never) + expect(created.ok).toBe(true) + // The row is owned once the chat's agent has run its thread, which its first start does. + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: true }) createMobileSessionTerminal = vi.fn() runtime = { ...(runtimeStub() as Record), @@ -802,12 +805,13 @@ describe('cross-version structured agent sessions', () => { ok: true }) await bootHost('b') - const current = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - expect(current).toBeGreaterThan(created.fence) + // Read before the stale send, so the answer is checked against the restarted record's own. + const restarted = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 + expect(restarted).toBeGreaterThan(created.fence) expect(await answer('agentSession.send', sendParams('stale', created.fence))).toMatchObject({ ok: true, - fence: store.getRecord(SESSION)?.lease.runtimeFence + fence: restarted }) }) From 4df1580f8a402dcb5b2e0f5b02aa726e735ca395 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:41:25 -0700 Subject: [PATCH 13/84] docs(native-chat): comments say a create starts nothing and ensure is gone Rewords the comments the first round found false: the structured worker is ready with its preamble, not when an attach returns; the create's pre-commit half ends at the host's create; the gate lists no ensure; the cross-version fixture's supportsCreate stub is explained by the create. Test helpers that started an agent through a client's ensure now start it as the delivery loop does. --- ...tured-agent-session-recovery-exits.test.ts | 5 ++--- ...ctured-agent-session-repeated-stop.test.ts | 2 +- ...ssion-restart-interruption-test-harness.ts | 19 +++++-------------- ...red-agent-session-surface-lifetime.test.ts | 9 +++------ ...t-session-wedged-profile-migration.test.ts | 11 +++-------- .../worker/local-worker-start.ts | 6 +++--- .../methods/structured-agent-session-gate.ts | 2 +- .../rpc/methods/structured-agent-session.ts | 4 ++-- .../structured-agent-session-host-fixture.ts | 6 +++--- 9 files changed, 23 insertions(+), 41 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts index bb126833519f..8de888188643 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts @@ -63,10 +63,9 @@ async function stopOwner(child: ReturnType): Promise spawnedOwners.delete(child) } -/** What a send's delivery or `agentSession.ensure` does: attach at the record's current fence. */ +/** What a send's delivery does: the start at the record's current fence. */ async function startAgent(): Promise { - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null - expect((await attachForTests(host, CALLER, hostTestAttachParams(fence))).ok).toBe(true) + expect((await startAgentForTests(host, SESSION)).ok).toBe(true) } function adapter(): StructuredAgentSessionAdapter { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts index b11b9ad19b1b..27b1c66c9fce 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts @@ -111,7 +111,7 @@ describe('a Stop pressed again', () => { }) replaceHostTestState({ store: relaunchedStore, host: relaunched }) host = relaunched - await startAgent({ host, store: relaunchedStore }) + await startAgent({ host }) cancelTurn.mockResolvedValueOnce({ cancelled: false }) expect(await stopTurn()).toMatchObject({ ok: true, value: { cancelled: false } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index a986cc40f318..94ca2a994433 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -10,7 +10,6 @@ import { AgentSessionRecoveryCapsule, AGENT_SESSION_RECOVERY_CAPSULE_FILE } from '../../runtime/agent-session-recovery-capsule' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { parseAgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' @@ -31,23 +30,15 @@ import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, HOST_TEST_THREAD as THREAD, - hostTestAttachParams, hostTestMessage } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { attachForTests } from './structured-agent-session-attach-test-support' +import { startAgentForTests } from './structured-agent-session-attach-test-support' -/** Starts the agent explicitly — the attach a client's ensure makes — for a test that needs a - * running child before its next step. Nothing else starts one ahead of a send. */ -export async function startAgent(state: { - host: StructuredAgentSessionHost - store: AgentSessionRecordStore -}): Promise { - const result = await attachForTests( - state.host, - CALLER, - hostTestAttachParams(state.store.getRecord(SESSION)?.lease.runtimeFence ?? null) - ) +/** Starts the agent as a queued message's delivery does, for a test that needs a running child + * before its next step. Nothing else starts one ahead of a send. */ +export async function startAgent(state: { host: StructuredAgentSessionHost }): Promise { + const result = await startAgentForTests(state.host, SESSION) expect(result.ok).toBe(true) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 0580cc654324..bd2e54a54db3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -41,7 +41,7 @@ import { import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { attachForTests } from './structured-agent-session-attach-test-support' +import { attachForTests, startAgentForTests } from './structured-agent-session-attach-test-support' const UNEXPECTED_PROVIDER_EXIT_OUTCOME = 'Codex stopped while this response was in progress. You can continue in this conversation.' @@ -103,12 +103,9 @@ async function attach(): Promise { expect(await attachForTests(host, CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) } -/** What a send's delivery or `agentSession.ensure` does: attach at the record's current fence. */ +/** What a send's delivery does: the start at the record's current fence. */ async function startAgent(): Promise { - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null - expect(await attachForTests(host, CALLER, hostTestAttachParams(fence))).toMatchObject({ - ok: true - }) + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: true }) } function envelope(method: string, fields: Record): AgentSessionMutationEnvelope { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 25c3736991e8..2b241ffe80e3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -369,14 +369,9 @@ describe('already-wedged profiles become usable on load', () => { await seedRunningTurn() openHost() - // The attach adjudicates the dead owner first, which moves the fence; like a client's ensure, - // it is retried at the fence the refusal names. - const stale = await attachForTests(host, CALLER, hostTestAttachParams(13)) - const fence = stale.ok ? 13 : (stale.refusal.currentFence ?? 13) - expect(stale.ok || stale.refusal.code === 'agent_session_checkpoint_stale').toBe(true) - expect(stale.ok || (await attachForTests(host, CALLER, hostTestAttachParams(fence))).ok).toBe( - true - ) + // The start adjudicates the dead owner first, which moves the fence, then reserves at the + // fence it reads from the record. + expect(await attachForTests(host, CALLER, hostTestAttachParams(13))).toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledOnce() expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index 7e10274435b5..a68ec621699b 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -178,9 +178,9 @@ export async function startLocalWorker(args: { persistWorkerReadinessStage(setupStage) failedStage = 'agent_readiness' - // A structured session is ready the moment its attach returns ok: there is no boot-to-idle - // gap and no terminal title to read an idle edge from. Only the repo's wait-for-setup policy - // still holds it back, and that gate has to be waited on explicitly here. + // A structured session has no boot-to-idle gap and no terminal title to read an idle edge + // from: its agent starts with the preamble below. Only the repo's wait-for-setup policy still + // holds it back, and that gate has to be waited on explicitly here. const wait = structuredSession ? await awaitStructuredWorkerSetupGate({ runtime, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts index 0bed563af38b..7ef281320872 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts @@ -48,7 +48,7 @@ export function requireStructuredHost(ctx: RpcContext): StructuredAgentSessionHo * sounds: * * - Starts, extends, retains or reads work -> `requireStructuredHost`. Revoked admission means - * no new turns, no new holds, no new reads. create, send, ensure, setOption, + * no new turns, no new holds, no new reads. create, send, setOption, * subscribe, hold, reveal, history, options and the status stream all live here. * - Stops or retires work the caller already owns -> `requireStructuredCleanupHost`. close, * cancel, unsubscribe and release live here. diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 4d0798bd7db3..3dba34f4d023 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -144,8 +144,8 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ reason: 'requestMalformed' }) } - // Everything up to `attach` is pre-commit, and answers with a refusal rather than a throw so - // a client can tell "nothing was created" from "the outcome is unknown". + // Everything up to the host's create is pre-commit, and answers with a refusal rather than a + // throw so a client can tell "nothing was created" from "the outcome is unknown". const prepared = await resolveUncommittedStructuredCreate(async () => { if ('worktree' in params) { const conflict = agentSessionFingerprintConflict( diff --git a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts index b89a1edb1427..ce1bb7282080 100644 --- a/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts +++ b/tests/e2e/cross-version-wire/structured-agent-session-host-fixture.ts @@ -24,9 +24,9 @@ export function structuredHostStub( restartResumeAll: vi.fn(async () => []), restartContinueAll: vi.fn(async () => ({ resumed: [], continued: [] })), create: vi.fn(async () => ({ ok: true, replayed: false, value: { sessionId } })), - // Attach-shaped entries take a client-supplied location, so the host is asked whether it - // supports creating there. A real host always answers; leaving it unstubbed made every - // `ensure` refuse for the harness's own reason rather than the location's. + // A create takes a client-supplied location, so the host is asked whether it supports + // creating there. A real host always answers; leaving it unstubbed made every create refuse + // for the harness's own reason rather than the location's. supportsCreate: vi.fn(() => true), conversationCommand: vi.fn(async () => ({ ok: true, From f97fa4d8915f15a7ed440c67578dec98226359f0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:41:46 -0700 Subject: [PATCH 14/84] docs(orchestration): a structured worker is created at rest and starts with its preamble --- ...orchestration-structured-worker-session.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) 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 adaadb9c30a3..4faf3d2b2f5f 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.ts @@ -3,8 +3,9 @@ * * Three things make this different from the PTY worker path, and all three live here: * - * - The session is created directly as structured, so readiness is the attach returning ok. There - * is no boot-to-idle gap to wait on and no `tui-idle` edge to read. + * - The session is created directly as structured, at rest; its preamble is its first message and + * starts its agent, so readiness is the agent taking the preamble. There is no boot-to-idle gap to + * wait on and no `tui-idle` edge to read. * - Nothing here keeps its agent running. The idle sweep leaves it running while its dispatch is * open, reading that from the orchestration database; once the dispatch settles the agent rests * like any chat's, and the next mail starts it. @@ -143,8 +144,8 @@ export async function createStructuredWorkerSession(args: { }) return { identity, host } } catch (error) { - // A start that fails after the session exists would otherwise strand a live provider child - // that no dispatch owns and that nothing else in the runtime will ever retire. + // A start that fails after the session exists would otherwise strand a chat (and, once its + // first message runs, a provider child) that no dispatch owns and nothing else will retire. structuredWorkerIdentities.forget(identity.handle) if (structuredCreateMayHaveCommitted(created)) { await discardStructuredWorkerSession(sessionId, args.runtime) @@ -154,12 +155,12 @@ export async function createStructuredWorkerSession(args: { } /** - * Whether a create may have attached a session, which is the question cleanup has to ask. + * Whether a create may have committed a session, which is the question cleanup has to ask. * - * `ok` is not the test. `commit` answers `agent_session_operation_unknown` when `attach` SUCCEEDED - * and only the tab publish failed, and a throw out of the commit half is past `attach` too — the - * pre-commit half never throws, it refuses. Both leave a session with a published tab and no - * binding, so nothing else in the runtime will ever retire it. Only a DEFINITIVE refusal + * `ok` is not the test. `commit` answers `agent_session_operation_unknown` when the host's create + * SUCCEEDED and only the tab publish failed, and a throw out of the commit half is past the create + * too — the pre-commit half never throws, it refuses. Both leave a session with a published tab and + * no binding, so nothing else in the runtime will ever retire it. Only a DEFINITIVE refusal * proves there is nothing to discard; everything else gets the best-effort close. */ function structuredCreateMayHaveCommitted( @@ -174,7 +175,7 @@ function structuredCreateMayHaveCommitted( * Stops the provider child, drops the DURABLE tab reference so nothing restores the chat after a * restart, and — only once the close came back without throwing — retires the background tab this * start published from the live snapshot. All three are no-ops for a session that was never - * attached, which is why a non-definitive refusal can reach here unconditionally. A close that + * created, which is why a non-definitive refusal can reach here unconditionally. A close that * threw leaves the tab alone: the child may still be running, and the tab is the way to reach it. * * Exported because a start can also fail AFTER `createStructuredWorkerSession` returned — on the From a8f167090b9a29292120c58fe5d433277dc55833 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:42:46 -0700 Subject: [PATCH 15/84] test(native-chat): type the seeded location as an execution location --- .../structured-agent-session-integration-create.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/runtime/structured-agent-session-integration-create.test.ts b/src/main/runtime/structured-agent-session-integration-create.test.ts index 1382ac861964..5dcd7a2ff06f 100644 --- a/src/main/runtime/structured-agent-session-integration-create.test.ts +++ b/src/main/runtime/structured-agent-session-integration-create.test.ts @@ -6,6 +6,7 @@ import { writeFile } from 'node:fs/promises' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' +import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import { attachFingerprintFields, type AgentSessionAttachParams @@ -124,11 +125,11 @@ describe('creating a structured codex chat over agentSession.*', () => { const host = await ensureStructuredAgentSessionHost(harness.hostConfig()) const { store } = host.deps const now = Date.now() - const location = { + const location: AgentSessionExecutionLocation = { executionHostId: 'local', wslDistro: null, workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' as const + workspaceKind: 'git-worktree' } const identity = { sessionId: SESSION, From 19ace3563ed25ec0d2d7252df429ca5ee3bc855f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:43:54 -0700 Subject: [PATCH 16/84] test(native-chat): type the launch prompt handover fixtures as the wire types --- .../comment-resolution-launch-delivery.test.tsx | 4 ++-- ...structured-agent-session-launch-prompt.test.ts | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx index 224867c00aa2..fc96c8d91d43 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx @@ -43,7 +43,7 @@ const PENDING = { reason: null, submittedAt: 1, resolvedAt: null, - handoverRecorded: true + handoverRecorded: true as const } function comment(overrides: Partial): PRComment { @@ -77,7 +77,7 @@ function resolution(): PendingPRCommentAiAck { comment: comment({ id: 20, url: 'https://github.com/acme/widgets/pull/42#issuecomment-9' }) } ], - githubTarget: { ...target, prRepo: { owner: 'acme', name: 'widgets' } }, + githubTarget: { ...target, prRepo: { owner: 'acme', repo: 'widgets' } }, githubResolveTarget: target } } diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts index 132d4600c53e..a63362d71979 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts @@ -36,7 +36,11 @@ function sendAnswer(clientMessageId: string, handoverRecorded = true) { cursor: { epoch: 'epoch-1', sequence: 1 }, value: { clientMessageId, - submission: { clientMessageId, ...PENDING, ...(handoverRecorded ? { handoverRecorded } : {}) } + submission: { + clientMessageId, + ...PENDING, + ...(handoverRecorded ? { handoverRecorded: true as const } : {}) + } } } } @@ -94,7 +98,12 @@ describe('settleStructuredAgentLaunchPrompt', () => { mocks.call.mockResolvedValue(sendAnswer(clientMessageId)) publishes( frame('snapshot', { clientMessageId, ...PENDING, handoverRecorded: true }), - frame('batch', { clientMessageId, ...PENDING, handoverRecorded: true, handedOverAt: 5 }) + frame('batch', { + clientMessageId, + ...PENDING, + handoverRecorded: true as const, + handedOverAt: 5 + }) ) await expect( @@ -149,7 +158,7 @@ describe('settleStructuredAgentLaunchPrompt', () => { const onPromptDelivered = vi.fn() const clientMessageId = stagedEntry!.clientMessageId mocks.call.mockResolvedValue(sendAnswer(clientMessageId)) - const queued = { clientMessageId, ...PENDING, handoverRecorded: true } + const queued = { clientMessageId, ...PENDING, handoverRecorded: true as const } publishes( frame('snapshot', queued), frame('batch', { From 9569ccb26d715174d4a5f2e2ed50f4b14932e31f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:45:29 -0700 Subject: [PATCH 17/84] test(native-chat): build the comment-resolution hook model without type assertions --- ...omment-resolution-launch-delivery.test.tsx | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx index fc96c8d91d43..9ab306a73f60 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx @@ -82,22 +82,27 @@ function resolution(): PendingPRCommentAiAck { } } +type AcknowledgementInput = Parameters[0] + function acknowledgement() { + const pendingCommentResolutionRef: AcknowledgementInput['pendingCommentResolutionRef'] = { + current: resolution() + } const model = { - addPRConversationComment: vi.fn(async () => ({ - ok: true as const, + addPRConversationComment: vi.fn(async () => ({ + ok: true, comment: comment({ id: 30 }) })), - addPRReviewCommentReply: vi.fn(async () => ({ - ok: true as const, + addPRReviewCommentReply: vi.fn(async () => ({ + ok: true, comment: comment({ id: 31, threadId: 'T1' }) })), - resolveReviewThread: vi.fn(async () => true), + resolveReviewThread: vi.fn(async () => true), asyncResultKeyRef: { current: REVIEW_KEY }, - claimedCommentResolutionRef: { current: null as PendingPRCommentAiAck | null }, - commentsRef: { current: [] as PRComment[] }, + claimedCommentResolutionRef: { current: null }, + commentsRef: { current: [] }, commentsSelectionClearTokenRef: { current: 0 }, - pendingCommentResolutionRef: { current: resolution() as PendingPRCommentAiAck | null }, + pendingCommentResolutionRef, commentResolutionLaunchAcceptedRef: { current: false }, setCommentResolutionAckBusyNow: vi.fn(), setComments: vi.fn(), @@ -105,13 +110,8 @@ function acknowledgement() { settings: null, fetchComments: vi.fn(async () => {}), fetchGitLabDetails: vi.fn(async () => {}) - } - const hook = renderHook(() => - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the model carries every member the hook reads; its state setters are no-op spies. - useChecksPanelAiAcknowledgement( - model as unknown as Parameters[0] - ) - ) + } satisfies AcknowledgementInput + const hook = renderHook(() => useChecksPanelAiAcknowledgement(model)) return { model, hook } } From 4fb0da01965c95133f59e58bb53e3e06c65a1384 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:51:25 -0700 Subject: [PATCH 18/84] fix(native-chat): a create retried after its operation row aged out answers the chat it made A create whose answer was lost before its tab published leaves the renderer holding the launch as unknown; reopened more than a day later, its retry under the same operation id found the row pruned and was refused as operation_expired, and the relaunch under a new id then met sessionExists, so the chat could never start. A create starts nothing, so an aged-out row proves nothing more: when the record already there is this create's (same identity, same adopted conversation or none), the create answers it at rest under a fresh row it settles as its own. A pending row an older host left when it died mid-create is re-run and settled the same way. The crash-mid-start cases now replay through host.create. --- ...ured-agent-session-crash-mid-start.test.ts | 78 +++++++++++++++++-- .../agent-session-at-rest-create.test.ts | 41 ++++++++++ .../runtime/agent-session-at-rest-create.ts | 52 +++++++++++-- 3 files changed, 158 insertions(+), 13 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts index a8a1cc229221..308b67bdfa37 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts @@ -159,8 +159,9 @@ describe('a host that dies while its Codex child is starting', () => { }) }) -// The create committed at rest before its first start ran, so the client's retry of a create it -// never heard back from replays that chat, and the next message's start is what starts an agent. +// The create committed at rest before its first start ran. The client's retry of a create it never +// heard back from answers that chat at rest, even once its operation row has aged out, and the +// next message's start is what starts an agent. describe('a first start cut short by the host dying', () => { const PAST_OPERATION_EXPIRY = AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS + 60_000 @@ -214,11 +215,17 @@ describe('a first start cut short by the host dying', () => { handoffStage: null, runtimeFence: 3 }) - if (elapsedMs === 0) { - // A retried create replays the chat it made, at rest; it starts nothing. - expect(await relaunched.create(CALLER, params)).toMatchObject({ ok: true, replayed: true }) - expect(restarted.connections).toHaveLength(0) - } + // Before, `_operation_expired` once the row aged out, and the client's relaunch under a new id + // was then refused as `sessionExists`. + expect(await relaunched.create(CALLER, params)).toMatchObject({ + ok: true, + replayed: true, + value: { sessionId: SESSION } + }) + expect( + store.getOperationRow(CALLER.callerKey, params.envelope.clientOperationId)?.outcome + ).toEqual({ status: 'succeeded', sessionId: SESSION }) + expect(restarted.connections).toHaveLength(0) expect(await startAgentForTests(relaunched, SESSION)).toMatchObject({ ok: true, @@ -236,3 +243,60 @@ describe('a first start cut short by the host dying', () => { expect(restarted.connections).toHaveLength(1) }) }) + +// An older host ran the agent inside its create and died there, leaving the create's row pending. +describe('a create an older host left pending when it died', () => { + it('answers the chat at rest on its retry, settles the row, and starts on the next message', async () => { + const params = hostTestAttachParams(null) + const operation = { + callerKey: CALLER.callerKey, + operationId: params.envelope.clientOperationId + } + const left = await openStore('dying') + const identity = { + sessionId: SESSION, + location: params.location, + provider: params.provider, + accountHome: params.accountHome + } + await left.createAtRest({ + ...identity, + claimKeyId: 'key-1', + operation: { ...operation, fingerprint: params.envelope.payloadFingerprint }, + now: NOW + }) + await left.reserveOwner({ + ...identity, + expectedFence: 1, + spawnToken: 'spawn-a', + claimKeyId: 'key-1', + handoffOperationId: operation.operationId, + probe: { outcome: 'reservation-unused' }, + operation: { ...operation, fingerprint: params.envelope.payloadFingerprint }, + now: NOW + }) + await crash(left) + + const restarted = fakeCodex() + const store = await openStore('relaunched') + const relaunched = host( + 'relaunched', + store, + Object.assign(adapterFor(restarted), { supportsCreate: () => true }), + { + mintSpawnToken: () => 'spawn-b', + probeOwner: async () => ({ outcome: 'indeterminate', reason: 'no token scan' }) + } + ) + await relaunched.restoreReadableSessions() + + expect(await relaunched.create(CALLER, params)).toMatchObject({ ok: true, replayed: true }) + expect(store.getOperationRow(operation.callerKey, operation.operationId)?.outcome).toEqual({ + status: 'succeeded', + sessionId: SESSION + }) + expect(restarted.connections).toHaveLength(0) + expect(await startAgentForTests(relaunched, SESSION)).toMatchObject({ ok: true }) + expect(restarted.connections).toHaveLength(1) + }) +}) diff --git a/src/main/runtime/agent-session-at-rest-create.test.ts b/src/main/runtime/agent-session-at-rest-create.test.ts index 34276079d8ca..fcf5c2b9540b 100644 --- a/src/main/runtime/agent-session-at-rest-create.test.ts +++ b/src/main/runtime/agent-session-at-rest-create.test.ts @@ -14,6 +14,10 @@ import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' +import { + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS +} from '../../shared/agent-session-host-authority' import { commitAgentSessionAtRestCreate, type AgentSessionAtRestCreateRequest @@ -249,6 +253,43 @@ describe('a create over a record an older host left after its start failed', () }) }) +describe('a create retried after its operation row aged out', () => { + const LATER = + NOW + AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS + AGENT_SESSION_OPERATION_FUTURE_SKEW_MS + 1 + + it('answers the record it founded, at rest, under a fresh row the create settles', () => { + const state = storeState() + const request = createRequest() + const { record } = commitAgentSessionAtRestCreate(state, request) + + const retried = commitAgentSessionAtRestCreate(state, { ...request, now: LATER }) + + expect(retried).toMatchObject({ replayed: true, record }) + expect(retried.operationRow).toMatchObject({ + operationId: request.operation.operationId, + outcome: { status: 'pending' } + }) + expect(retried.operationRow.expiresAt).toBeGreaterThan(LATER) + }) + + it('stays expired for a record that is not the one this create founded', () => { + const request = createRequest() + const otherHome = { variable: 'CLAUDE_CONFIG_DIR' as const, path: '/home/dev/.claude-work' } + for (const founded of [ + createRequest({ ...request, accountHome: otherHome }), + createRequest({ ...request, adoptedHandleLink: adoptedLink() }) + ]) { + const state = storeState() + commitAgentSessionAtRestCreate(state, founded) + expect(() => commitAgentSessionAtRestCreate(state, { ...request, now: LATER })).toThrow( + expect.objectContaining({ + refusal: expect.objectContaining({ code: 'agent_session_operation_expired' }) + }) + ) + } + }) +}) + describe('an adopting create at rest', () => { it('seeds the chain with the adopted link alone, at the record fence its first start moves', () => { const link = adoptedLink() diff --git a/src/main/runtime/agent-session-at-rest-create.ts b/src/main/runtime/agent-session-at-rest-create.ts index 914cfa887cfc..bd2ee7d0e6c3 100644 --- a/src/main/runtime/agent-session-at-rest-create.ts +++ b/src/main/runtime/agent-session-at-rest-create.ts @@ -52,6 +52,16 @@ export function commitAgentSessionAtRestCreate( ): AgentSessionAtRestCreateResult { const decision = evaluateAgentSessionReserveOperation(state, request) if (decision.decision === 'refused') { + const existing = state.records.get(request.sessionId) + // An aged-out row proves nothing more, and a create starts nothing: the chat it would answer + // with is the record already here, under a fresh row the create settles as it would its own. + if ( + decision.code === 'agent_session_operation_expired' && + existing && + sameCreate(existing, request) + ) { + return recordOperationRow(state, existing, request, true) + } throw agentSessionRefusalError(decision.code, decision.details) } if (decision.decision === 'replay') { @@ -86,13 +96,46 @@ export function commitAgentSessionAtRestCreate( lease: { ...founded.lease, runtimeFence: existing.lease.runtimeFence } } : founded - const operationRow = pendingAgentSessionOperationRow({ ...request.operation, now: request.now }) state.records.set(record.sessionId, record) + return recordOperationRow(state, record, request, false) +} + +function recordOperationRow( + state: AgentSessionStoreState, + record: AgentSessionRecord, + request: AgentSessionAtRestCreateRequest, + replayed: boolean +): AgentSessionAtRestCreateResult { + const operationRow = pendingAgentSessionOperationRow({ ...request.operation, now: request.now }) state.operations.set( agentSessionOperationKey(operationRow.callerKey, operationRow.operationId), operationRow ) - return { record, operationRow, replayed: false } + return { record, operationRow, replayed } +} + +/** The record this create founded: same identity, and the conversation it adopted, if any. */ +function sameCreate(existing: AgentSessionRecord, request: AgentSessionAtRestCreateRequest) { + const adoptedRoot = request.adoptedHandleLink + ? agentSessionProviderHandleRoot(request.adoptedHandleLink.handle) + : null + const heldAdoption = existing.providerHandleChain.find((link) => link.origin === 'adopted') + return ( + sameIdentity(existing, request) && + (adoptedRoot === null + ? heldAdoption === undefined + : heldAdoption !== undefined && + agentSessionProviderHandleRoot(heldAdoption.handle) === adoptedRoot) + ) +} + +function sameIdentity(existing: AgentSessionRecord, request: AgentSessionAtRestCreateRequest) { + return ( + agentSessionExecutionLocationsEqual(existing.location, request.location) && + existing.provider === request.provider && + existing.accountHome.variable === request.accountHome.variable && + existing.accountHome.path === request.accountHome.path + ) } /** @@ -109,10 +152,7 @@ function refoundable( !request.adoptedHandleLink && existing.providerHandleChain.length === 0 && agentSessionLeaseOwnerVerdict(existing.lease) === 'exited' && - agentSessionExecutionLocationsEqual(existing.location, request.location) && - existing.provider === request.provider && - existing.accountHome.variable === request.accountHome.variable && - existing.accountHome.path === request.accountHome.path + sameIdentity(existing, request) ) } From d2a25c5ab23fd482e7ab330e9a24bdaaf85e3f1c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:59:33 -0700 Subject: [PATCH 19/84] fix(native-chat): a launch prompt waits for its message's own final state before it counts as delivered The wait now follows the first message to its final state rather than to its hand-over, and has no timer of its own: accepted (the agent took it) counts as delivered and runs the caller's outward side effects; rejected (a start that failed for good, or a chat closed mid-wait) and unknown do not. A start the host retries keeps the message queued, so the wait spans the retry schedule. Each launch prompt has one read, shared by whoever asks and tied to the launch call; a renderer reload ends it with nothing written. Tests drive a failing first start through the checks-panel acknowledgement, the notes send and the browser annotation send: a retried start the agent then takes writes exactly once and only after acceptance, a message rejected after its tries writes nothing, and a chat closed mid-wait writes nothing and keeps the notes. --- .../use-browser-page-annotation-send.test.tsx | 64 ++++++ .../components/editor/NotesSendMenu.test.tsx | 72 ++++++ ...ed-agent-session-outbox-admission.test.tsx | 17 +- ...omment-resolution-launch-delivery.test.tsx | 120 +++++----- ...ed-agent-session-launch-prompt-handover.ts | 79 ++++--- ...gent-session-launch-prompt-test-support.ts | 117 ++++++++++ ...ctured-agent-session-launch-prompt.test.ts | 216 ++++++------------ .../structured-agent-session-launch-prompt.ts | 19 +- 8 files changed, 444 insertions(+), 260 deletions(-) create mode 100644 src/renderer/src/lib/structured-agent-session-launch-prompt-test-support.ts diff --git a/src/renderer/src/components/browser-pane/annotate/use-browser-page-annotation-send.test.tsx b/src/renderer/src/components/browser-pane/annotate/use-browser-page-annotation-send.test.tsx index 9c7ca72e954d..52b040c53c59 100644 --- a/src/renderer/src/components/browser-pane/annotate/use-browser-page-annotation-send.test.tsx +++ b/src/renderer/src/components/browser-pane/annotate/use-browser-page-annotation-send.test.tsx @@ -8,6 +8,13 @@ import { createTestStore } from '@/store/slices/browser-slice-test-harness' import type { OpenAgentSendPopoverTargetModeArgs } from '@/store/slices/ui' import { BrowserPageAnnotationTray } from './browser-page-annotation-tray' import { useBrowserPageAnnotationSend } from './use-browser-page-annotation-send' +import { enqueueStructuredAgentSessionLaunchPrompt } from '@/components/native-chat/structured-agent-session-outbox-storage' +import { settleStructuredAgentLaunchPrompt } from '@/lib/structured-agent-session-launch-prompt' +import { + FIRST_START_FAILS, + firstMessageStream, + play +} from '@/lib/structured-agent-session-launch-prompt-test-support' const state = vi.hoisted((): { store?: ReturnType } => ({})) vi.mock('@/store', () => ({ @@ -21,6 +28,11 @@ vi.mock('@/store', () => ({ } })) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +const client = vi.hoisted(() => ({ call: vi.fn(), subscribe: vi.fn() })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: client.call, + subscribeStructuredAgentSession: client.subscribe +})) vi.mock('./BrowserAnnotationSendMenuContent', () => ({ BrowserAnnotationSendMenuContent: ({ onPromptDelivered }: { onPromptDelivered?: () => void }) => ( @@ -214,3 +226,55 @@ describe('website annotation delivery', () => { expect(notes()).toEqual([]) }) }) + +// Sent to a new chat, whose first message starts its agent: the tray clears only once the agent +// takes the prompt, and a start that never does (or a chat closed mid-wait) leaves it as it was. +describe('website annotations sent to a new chat', () => { + async function sendToNewChat() { + const view = mount() + act(() => view.result.current.handleAnnotationTraySendOpenChange(true)) + const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', mode!.prompt) + const host = firstMessageStream(client, stagedEntry!.clientMessageId) + const delivery = settleStructuredAgentLaunchPrompt({ + launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), + options: { + prompt: mode!.prompt, + promptDelivery: 'submit-after-ready', + onPromptDelivered: () => act(() => mode!.onPromptDelivered?.()) + }, + stagedEntry + })! + return { stream: await host, delivery } + } + + beforeEach(() => { + localStorage.clear() + client.call.mockReset() + client.subscribe.mockReset() + }) + + it('clears the tray once, after a retried start takes the prompt', async () => { + const { stream, delivery } = await sendToNewChat() + const [retry, handedOver, accepted] = FIRST_START_FAILS.retriedThenTaken + stream.next(retry!) + stream.next(handedOver!) + await Promise.resolve() + expect(notes()).toHaveLength(1) + + stream.next(accepted!) + await expect(delivery).resolves.toMatchObject({ delivered: true }) + expect(notes()).toEqual([]) + expect(notes('page-2')).toHaveLength(1) + }) + + it.each([ + ['rejected after its tries', FIRST_START_FAILS.rejectedAfterTries], + ['withdrawn when its chat closes mid-wait', FIRST_START_FAILS.chatClosed] + ])('keeps the annotations when the first message is %s', async (_case, end) => { + const { stream, delivery } = await sendToNewChat() + play(stream, end) + + await expect(delivery).resolves.toMatchObject({ delivered: false }) + expect(notes()).toHaveLength(1) + }) +}) diff --git a/src/renderer/src/components/editor/NotesSendMenu.test.tsx b/src/renderer/src/components/editor/NotesSendMenu.test.tsx index 64556e6b83db..6e4a69bdefe0 100644 --- a/src/renderer/src/components/editor/NotesSendMenu.test.tsx +++ b/src/renderer/src/components/editor/NotesSendMenu.test.tsx @@ -1,6 +1,15 @@ +// @vitest-environment happy-dom + import React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { buildNotesSendTargetModeId, NotesSendMenu } from './NotesSendMenu' +import { enqueueStructuredAgentSessionLaunchPrompt } from '@/components/native-chat/structured-agent-session-outbox-storage' +import { settleStructuredAgentLaunchPrompt } from '@/lib/structured-agent-session-launch-prompt' +import { + FIRST_START_FAILS, + firstMessageStream, + play +} from '@/lib/structured-agent-session-launch-prompt-test-support' type ReactElementLike = { type: unknown @@ -25,6 +34,12 @@ const storeMocks = vi.hoisted(() => ({ } })) +const client = vi.hoisted(() => ({ call: vi.fn(), subscribe: vi.fn() })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: client.call, + subscribeStructuredAgentSession: client.subscribe +})) + vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() return { @@ -437,3 +452,60 @@ describe('NotesSendMenu', () => { ) }) }) + +// Sent to a new chat, whose first message starts its agent: the notes are marked sent only once +// the agent takes the prompt, and a start that never does (or a chat closed mid-wait) keeps them. +describe('NotesSendMenu sending to a new chat', () => { + async function sendToNewChat(onDelivered: (notes: readonly TestNote[]) => void) { + resetHookRuntime() + storeMocks.openAgentSendPopoverTargetMode.mockReset() + localStorage.clear() + const tree = renderMenu({ onDelivered }) + const openChange = findByType(tree, 'DropdownMenu').props.onOpenChange + if (typeof openChange !== 'function') { + throw new Error('the menu takes no open change') + } + openChange(true) + const mode: { prompt: string; onPromptDelivered: () => void } = + storeMocks.openAgentSendPopoverTargetMode.mock.calls[0][0] + const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', mode.prompt) + const host = firstMessageStream(client, stagedEntry!.clientMessageId) + const delivery = settleStructuredAgentLaunchPrompt({ + launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), + options: { + prompt: mode.prompt, + promptDelivery: 'submit-after-ready', + onPromptDelivered: mode.onPromptDelivered + }, + stagedEntry + })! + return { stream: await host, delivery } + } + + it('marks the notes sent once, after a retried start takes the prompt', async () => { + const onDelivered = vi.fn() + const { stream, delivery } = await sendToNewChat(onDelivered) + const [retry, handedOver, accepted] = FIRST_START_FAILS.retriedThenTaken + stream.next(retry!) + stream.next(handedOver!) + await Promise.resolve() + expect(onDelivered).not.toHaveBeenCalled() + + stream.next(accepted!) + await expect(delivery).resolves.toMatchObject({ delivered: true }) + expect(onDelivered).toHaveBeenCalledOnce() + expect(onDelivered).toHaveBeenCalledWith([{ id: 'note-1' }]) + }) + + it.each([ + ['rejected after its tries', FIRST_START_FAILS.rejectedAfterTries], + ['withdrawn when its chat closes mid-wait', FIRST_START_FAILS.chatClosed] + ])('keeps the notes when the first message is %s', async (_case, end) => { + const onDelivered = vi.fn() + const { stream, delivery } = await sendToNewChat(onDelivered) + play(stream, end) + + await expect(delivery).resolves.toMatchObject({ delivered: false }) + expect(onDelivered).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx index 8a215319ec68..383b7d28477a 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx @@ -14,13 +14,20 @@ const mocks = vi.hoisted(() => ({ call: vi.fn() })) +const launchClient = vi.hoisted(() => ({ subscribe: vi.fn() })) vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: mocks.call + callStructuredAgentSession: mocks.call, + subscribeStructuredAgentSession: launchClient.subscribe })) import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' import { enqueueStructuredAgentSessionLaunchPrompt } from './structured-agent-session-outbox-storage' import { settleStructuredAgentLaunchPrompt } from '@/lib/structured-agent-session-launch-prompt' +import { + FIRST_START_FAILS, + firstMessageStream, + play +} from '@/lib/structured-agent-session-launch-prompt-test-support' import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' @@ -210,9 +217,17 @@ describe('structured agent session outbox admission', () => { // The launch settlement dispatches outside this hook's single-flight, so nothing may race it. expect(sentTexts()).not.toContain('after launch') + // A pending launch prompt is delivered once the agent takes it. + const taken = firstMessageStream( + { call: vi.fn(), subscribe: launchClient.subscribe }, + stagedEntry.clientMessageId + ) await act(async () => admission.resolve(submissionResult(stagedEntry.clientMessageId, dispatchState, 1)) ) + if (dispatchState === 'pending') { + play(await taken, FIRST_START_FAILS.retriedThenTaken) + } await expect(delivery).resolves.toEqual({ delivered: true, failureNotified: false }) await waitFor(() => expect(sentTexts()).toContain('after launch')) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx index 9ab306a73f60..afd94b68a74a 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx @@ -2,7 +2,7 @@ // "Resolve comments with AI" writes to GitHub (fixing replies, resolved threads) only once the // launch prompt reaches the agent. The chat's first message starts that agent, so a start that -// fails must post nothing and hand the comments back for a retry. +// fails must post nothing and hand the comments back for a retry; one a retry clears posts once. import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -23,10 +23,14 @@ vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab: mocks.lau vi.mock('@/lib/focus-terminal-tab-surface', () => ({ focusTerminalTabSurface: vi.fn() })) vi.mock('sonner', () => ({ toast: { error: mocks.toastError, success: mocks.toastSuccess } })) -import type { AgentJournalSubmission } from '../../../../../shared/agent-session-journal-types' import type { PRComment } from '../../../../../shared/github/comment-types' import { enqueueStructuredAgentSessionLaunchPrompt } from '@/components/native-chat/structured-agent-session-outbox-storage' import { settleStructuredAgentLaunchPrompt } from '@/lib/structured-agent-session-launch-prompt' +import { + FIRST_START_FAILS, + firstMessageStream, + play +} from '@/lib/structured-agent-session-launch-prompt-test-support' import { clearPendingPRCommentAiAck, type PendingPRCommentAiAck @@ -35,16 +39,6 @@ import { runSourceControlAgentActionStart } from '../runSourceControlAgentAction import { useChecksPanelAiAcknowledgement } from './use-checks-panel-ai-acknowledgement' const REVIEW_KEY = 'repo-1::42::sha-1' -const PENDING = { - fence: 1, - payloadFingerprint: 'fingerprint', - dispatchState: 'pending' as const, - providerItemId: null, - reason: null, - submittedAt: 1, - resolvedAt: null, - handoverRecorded: true as const -} function comment(overrides: Partial): PRComment { return { @@ -115,39 +109,10 @@ function acknowledgement() { return { model, hook } } -/** The new chat's first message, as the host answers it (accepted) and then publishes it. */ -function hostAnswers(clientMessageId: string, final: Partial): void { - const submission = { clientMessageId, ...PENDING } - mocks.call.mockResolvedValue({ - ok: true, - replayed: false, - fence: 1, - cursor: { epoch: 'epoch-1', sequence: 1 }, - value: { clientMessageId, submission } - }) - mocks.subscribe.mockImplementation(async (_target, _params, onEvent) => { - queueMicrotask(() => - onEvent({ - type: 'batch', - sessionId: 'session-1', - batch: { - cursor: { epoch: 'epoch-1', sequence: 3 }, - items: [], - removedItemIds: [], - submissions: [{ ...submission, ...final }] - } - }) - ) - return { unsubscribe: vi.fn() } - }) -} - -async function resolveCommentsWithAi( - hook: ReturnType['hook'], - firstMessageEndsAs: Partial -) { +/** Starts the launch; the first message's stream is the host's to play. */ +function resolveCommentsWithAi(hook: ReturnType['hook']) { const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'Fix the comments') - hostAnswers(stagedEntry!.clientMessageId, firstMessageEndsAs) + const host = firstMessageStream(mocks, stagedEntry!.clientMessageId) mocks.launchAgentInNewTab.mockImplementation(() => ({ surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'session-1' }, promptDeliveryResult: settleStructuredAgentLaunchPrompt({ @@ -156,7 +121,7 @@ async function resolveCommentsWithAi( stagedEntry }) })) - return act(() => + const launched = act(() => runSourceControlAgentActionStart({ selectedAgent: 'claude', trimmedCommandInput: 'Fix the comments', @@ -181,6 +146,7 @@ async function resolveCommentsWithAi( onClose: vi.fn() }) ) + return { host, launched } } describe('Resolve comments with AI, when the chat starts on its first message', () => { @@ -193,31 +159,55 @@ describe('Resolve comments with AI, when the chat starts on its first message', cleanup() }) - it.each(['notSignedIn', 'providerMissing', 'providerExited'])( - 'posts no reply and resolves no thread when the start fails (%s), and hands the comments back', - async (kind) => { - const { model, hook } = acknowledgement() - await expect( - resolveCommentsWithAi(hook, { dispatchState: 'rejected', rejection: { kind } }) - ).resolves.toBe(false) - - expect(model.addPRReviewCommentReply).not.toHaveBeenCalled() - expect(model.addPRConversationComment).not.toHaveBeenCalled() - expect(model.resolveReviewThread).not.toHaveBeenCalled() - expect(mocks.toastSuccess).not.toHaveBeenCalled() - // Handed back for a retry, not consumed. - expect(model.pendingCommentResolutionRef.current).toMatchObject({ - reviewContextKey: REVIEW_KEY - }) - } - ) + function expectNothingPosted(model: ReturnType['model']) { + expect(model.addPRReviewCommentReply).not.toHaveBeenCalled() + expect(model.addPRConversationComment).not.toHaveBeenCalled() + expect(model.resolveReviewThread).not.toHaveBeenCalled() + expect(mocks.toastSuccess).not.toHaveBeenCalled() + } - it('posts its reply and resolves its thread exactly once when the agent takes the prompt', async () => { + it('posts its reply and resolves its thread exactly once, after a retried start takes the prompt', async () => { const { model, hook } = acknowledgement() - await expect(resolveCommentsWithAi(hook, { handedOverAt: 5 })).resolves.toBe(true) + const { host, launched } = resolveCommentsWithAi(hook) + const stream = await host + const [retry, handedOver, accepted] = FIRST_START_FAILS.retriedThenTaken + await act(async () => { + stream.next(retry!) + stream.next(handedOver!) + }) + // The agent is still starting: nothing is posted, and the comments stay claimed. + expectNothingPosted(model) + expect(model.claimedCommentResolutionRef.current).toMatchObject({ + reviewContextKey: REVIEW_KEY + }) + + stream.next(accepted!) + await expect(launched).resolves.toBe(true) await vi.waitFor(() => expect(model.resolveReviewThread).toHaveBeenCalledTimes(1)) expect(model.addPRConversationComment).toHaveBeenCalledTimes(1) expect(model.pendingCommentResolutionRef.current).toBeNull() }) + + it.each([ + ['rejected after its tries', FIRST_START_FAILS.rejectedAfterTries], + ['withdrawn when its chat closes mid-wait', FIRST_START_FAILS.chatClosed], + ...['notSignedIn', 'providerMissing', 'providerExited'].map( + (kind) => + [ + `refused at once (${kind})`, + [{ dispatchState: 'rejected' as const, rejection: { kind } }] + ] as const + ) + ])('posts nothing when the first message is %s, and hands the comments back', async (_c, end) => { + const { model, hook } = acknowledgement() + const { host, launched } = resolveCommentsWithAi(hook) + play(await host, end) + + await expect(launched).resolves.toBe(false) + expectNothingPosted(model) + expect(model.pendingCommentResolutionRef.current).toMatchObject({ + reviewContextKey: REVIEW_KEY + }) + }) }) diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt-handover.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt-handover.ts index b759d04359f5..66d0806442b1 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt-handover.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt-handover.ts @@ -1,60 +1,67 @@ -// When a launch prompt counts as delivered: once the host hands it to the agent. A chat's first -// message starts its agent, and the host answers the send when it accepts the message, before that -// start; a caller that writes outward on delivery (GitHub replies, cleared notes) must not do so for -// an agent that never started. Read from the session's own publication, so an older host needs no -// new field: one that predates the hand-over record answers a send only once it is handed over. +// When a launch prompt counts as delivered: once the agent has taken it. A chat's first message +// starts its agent, and the host answers the send when it accepts the message, before that start; +// a caller that writes outward on delivery (GitHub replies, cleared notes) must not do so for an +// agent that never started. So the wait follows the message to its own final state, read from the +// session's publication: no new wire field, and no timer of its own. A start the host retries +// keeps the message queued, and the retry schedule ends in a verdict. import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import { isQueuedAgentJournalSubmission } from '../../../shared/agent-session-queued-submission' import { subscribeStructuredAgentSession } from '@/runtime/structured-agent-session-client' -/** - * Past the longest a start can take to reach a verdict: three retried refusals wait 15 s, 60 s and - * 5 min, and a start that never finishes is stopped by the host's idle sweep within 35 min. - */ -export const STRUCTURED_LAUNCH_PROMPT_HANDOVER_CAP_MS = 45 * 60_000 +type LaunchPromptVerdict = 'taken' | 'not-taken' | 'waiting' -type Handover = 'handed-over' | 'not-delivered' | 'waiting' - -/** `waiting` covers a start the host is still retrying: the message stays queued meanwhile. */ -export function structuredLaunchPromptHandover( - submission: Pick -): Handover { - if (submission.dispatchState === 'accepted') { - return 'handed-over' - } - if (submission.dispatchState === 'pending') { - return isQueuedAgentJournalSubmission(submission) ? 'waiting' : 'handed-over' +export function structuredLaunchPromptVerdict( + submission: Pick +): LaunchPromptVerdict { + switch (submission.dispatchState) { + case 'accepted': + return 'taken' + case 'pending': + return 'waiting' + // Rejected is a start that failed for good, or a chat closed first; unknown is a hand-over + // nobody can vouch for. + case 'rejected': + case 'unknown': + return 'not-taken' } - // Rejected is a start that failed for good; unknown is a hand-over nobody can vouch for. - return 'not-delivered' } -/** Whether the host handed the launch prompt to the chat's agent. Anything that ends the read - * first (the stream failing, the cap) answers no: the caller keeps what it would have written. */ -export function awaitStructuredLaunchPromptHandover( +// One read per launch prompt, whoever asks. +const inFlight = new Map>() + +/** Whether the chat's agent took the launch prompt. Anything that ends the read first (the chat + * closing, the stream failing) answers no: the caller keeps what it would have written. */ +export function awaitStructuredLaunchPromptTaken( sessionId: string, - clientMessageId: string, - capMs = STRUCTURED_LAUNCH_PROMPT_HANDOVER_CAP_MS + clientMessageId: string ): Promise { + const key = `${sessionId}:${clientMessageId}` + const existing = inFlight.get(key) + if (existing) { + return existing + } + const read = readUntilFinal(sessionId, clientMessageId).finally(() => inFlight.delete(key)) + inFlight.set(key, read) + return read +} + +function readUntilFinal(sessionId: string, clientMessageId: string): Promise { return new Promise((resolve) => { let settled = false let unsubscribe: (() => void) | null = null - const finish = (delivered: boolean): void => { + const finish = (taken: boolean): void => { if (settled) { return } settled = true - clearTimeout(timer) unsubscribe?.() - resolve(delivered) + resolve(taken) } - const timer = setTimeout(() => finish(false), capMs) const read = (submissions: readonly AgentJournalSubmission[]): void => { const submission = submissions.find((entry) => entry.clientMessageId === clientMessageId) - const handover = submission ? structuredLaunchPromptHandover(submission) : 'waiting' - if (handover !== 'waiting') { - finish(handover === 'handed-over') + const verdict = submission ? structuredLaunchPromptVerdict(submission) : 'waiting' + if (verdict !== 'waiting') { + finish(verdict === 'taken') } } subscribeStructuredAgentSession( diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt-test-support.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt-test-support.ts new file mode 100644 index 000000000000..48b016d07ad7 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt-test-support.ts @@ -0,0 +1,117 @@ +// A new chat's first message as the host answers and then publishes it, for tests of what a launch +// prompt's caller does on delivery. The caller's test mocks `@/runtime/structured-agent-session-client` +// with the two spies handed in here. + +import { vi, type Mock } from 'vitest' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' + +export type LaunchPromptClientMock = { call: Mock; subscribe: Mock } + +type FirstMessage = Omit + +const QUEUED: FirstMessage = { + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null, + handoverRecorded: true +} + +const RETRYING = (attempts: number): Partial => ({ + startFailure: { + attempts, + reason: 'An account switch is in progress.', + rejection: { kind: 'accountSwitchInProgress' }, + failedAt: attempts, + nextAttemptAt: attempts + 15_000 + } +}) + +/** What becomes of a first message whose agent's first start fails. */ +export const FIRST_START_FAILS = { + /** The retry starts the agent, which takes the message. */ + retriedThenTaken: [ + RETRYING(1), + { handedOverAt: 20_000 }, + { dispatchState: 'accepted', resolvedAt: 20_001 } + ], + /** Every start fails; the last try rejects the message. */ + rejectedAfterTries: [ + RETRYING(1), + RETRYING(2), + RETRYING(3), + { dispatchState: 'rejected', rejection: { kind: 'accountSwitchInProgress' } } + ], + /** The person closes the chat while it waits: the host withdraws the message. */ + chatClosed: [RETRYING(1), { dispatchState: 'rejected', rejection: { kind: 'chatClosed' } }] +} satisfies Record[]> + +export type FirstMessageStream = { + /** Publishes the message's next state, as a journal batch. */ + next: (change: Partial) => void + /** Whether the reader of the stream is still subscribed. */ + open: () => boolean +} + +/** Answers the send as accepted-but-queued and hands back the stream the host then publishes. */ +export function firstMessageStream( + client: LaunchPromptClientMock, + clientMessageId: string +): Promise { + let current: AgentJournalSubmission = { clientMessageId, ...QUEUED } + client.call.mockResolvedValue({ + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value: { clientMessageId, submission: current } + }) + return new Promise((resolve) => { + client.subscribe.mockImplementation( + async ( + _target: unknown, + _params: unknown, + onEvent: (e: AgentSessionSubscribeEvent) => void + ) => { + let subscribed = true + const publish = (submission: AgentJournalSubmission): void => + onEvent({ + type: 'batch', + sessionId: 'session-1', + batch: { + cursor: { epoch: 'epoch-1', sequence: 2 }, + items: [], + removedItemIds: [], + submissions: [submission] + } + }) + queueMicrotask(() => { + publish(current) + resolve({ + next: (change) => { + current = { ...current, ...change } + publish(current) + }, + open: () => subscribed + }) + }) + return { + unsubscribe: vi.fn(() => { + subscribed = false + }) + } + } + ) + }) +} + +/** Plays a whole outcome on the stream. */ +export function play(stream: FirstMessageStream, changes: readonly Partial[]): void { + for (const change of changes) { + stream.next(change) + } +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts index a63362d71979..abd65663f6b3 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts @@ -6,80 +6,30 @@ import { mutateStructuredAgentSessionLaunchPrompt } from '@/components/native-chat/structured-agent-session-outbox-storage' -const mocks = vi.hoisted(() => ({ call: vi.fn(), subscribe: vi.fn(), unsubscribe: vi.fn() })) +const mocks = vi.hoisted(() => ({ call: vi.fn(), subscribe: vi.fn() })) vi.mock('@/runtime/structured-agent-session-client', () => ({ callStructuredAgentSession: mocks.call, subscribeStructuredAgentSession: mocks.subscribe })) -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' import { settleStructuredAgentLaunchPrompt } from './structured-agent-session-launch-prompt' - -const PENDING = { - fence: 1, - payloadFingerprint: 'fingerprint', - dispatchState: 'pending' as const, - providerItemId: null, - reason: null, - submittedAt: 1, - resolvedAt: null -} - -/** The host's answer at acceptance; a host that predates the hand-over record omits it. */ -function sendAnswer(clientMessageId: string, handoverRecorded = true) { - return { - ok: true, - replayed: false, - fence: 1, - cursor: { epoch: 'epoch-1', sequence: 1 }, - value: { - clientMessageId, - submission: { - clientMessageId, - ...PENDING, - ...(handoverRecorded ? { handoverRecorded: true as const } : {}) - } - } - } -} - -function frame( - type: 'snapshot' | 'batch', - submission: AgentJournalSubmission -): AgentSessionSubscribeEvent { - const cursor = { epoch: 'epoch-1', sequence: 2 } - return type === 'batch' - ? { - type, - sessionId: 'session-1', - batch: { cursor, items: [], removedItemIds: [], submissions: [submission] } - } - : { - type, - sessionId: 'session-1', - fence: 2, - page: { - sessionId: 'session-1', - epoch: 'epoch-1', - direction: 'tail', - items: [], - removedItemIds: [], - submissions: [submission], - window: { oldest: null, newest: null, nextCursor: cursor }, - hasOlder: false, - hasNewer: false - } - } -} - -/** The session's publication after the send, frame by frame, as the host would stream it. */ -function publishes(...events: AgentSessionSubscribeEvent[]): void { - mocks.subscribe.mockImplementation(async (_target, _params, onEvent) => { - queueMicrotask(() => events.forEach((event) => onEvent(event))) - return { unsubscribe: mocks.unsubscribe } +import { awaitStructuredLaunchPromptTaken } from './structured-agent-session-launch-prompt-handover' +import { + FIRST_START_FAILS, + firstMessageStream, + play +} from './structured-agent-session-launch-prompt-test-support' + +function launch(onPromptDelivered = vi.fn()) { + const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'review this') + const stream = firstMessageStream(mocks, stagedEntry!.clientMessageId) + const result = settleStructuredAgentLaunchPrompt({ + launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), + options: { prompt: 'review this', onPromptDelivered }, + stagedEntry }) + return { stagedEntry, stream, result: result!, onPromptDelivered } } describe('settleStructuredAgentLaunchPrompt', () => { @@ -91,34 +41,63 @@ describe('settleStructuredAgentLaunchPrompt', () => { ) }) - it('reports a launch prompt delivered once the host hands it over, retaining it for the provider echo', async () => { + it('reports a launch prompt delivered only once the agent a retried start brought up takes it', async () => { + const { stream, result, onPromptDelivered } = launch() + const host = await stream + const [retry, handedOver, accepted] = FIRST_START_FAILS.retriedThenTaken + + host.next(retry!) + host.next(handedOver!) + await Promise.resolve() + // Still waiting: nothing outward has happened, and the entry waits for the provider echo. + expect(onPromptDelivered).not.toHaveBeenCalled() + const persisted = JSON.parse(localStorage.getItem(localStorage.key(0)!) ?? '[]') as { + state: string + }[] + expect(persisted).toMatchObject([{ state: 'dispatching' }]) + + host.next(accepted!) + await expect(result).resolves.toEqual({ delivered: true, failureNotified: false }) + expect(onPromptDelivered).toHaveBeenCalledOnce() + expect(host.open()).toBe(false) + }) + + it.each([ + ['rejected after its tries', FIRST_START_FAILS.rejectedAfterTries], + ['withdrawn when its chat closes mid-wait', FIRST_START_FAILS.chatClosed] + ])('reports a launch prompt %s undelivered', async (_case, outcome) => { + const { stream, result, onPromptDelivered } = launch() + play(await stream, outcome) + + await expect(result).resolves.toEqual({ delivered: false, failureNotified: false }) + expect(onPromptDelivered).not.toHaveBeenCalled() + }) + + it('reports undelivered when the session stream fails before a verdict', async () => { const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'review this') - const onPromptDelivered = vi.fn() - const clientMessageId = stagedEntry!.clientMessageId - mocks.call.mockResolvedValue(sendAnswer(clientMessageId)) - publishes( - frame('snapshot', { clientMessageId, ...PENDING, handoverRecorded: true }), - frame('batch', { - clientMessageId, - ...PENDING, - handoverRecorded: true as const, - handedOverAt: 5 - }) - ) + void firstMessageStream(mocks, stagedEntry!.clientMessageId) + mocks.subscribe.mockImplementation(async (_target, _params, _onEvent, onError) => { + queueMicrotask(() => onError(new Error('stream closed'))) + return { unsubscribe: vi.fn() } + }) await expect( settleStructuredAgentLaunchPrompt({ launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), - options: { prompt: 'review this', onPromptDelivered }, + options: { prompt: 'review this' }, stagedEntry }) - ).resolves.toEqual({ delivered: true, failureNotified: false }) + ).resolves.toEqual({ delivered: false, failureNotified: false }) + }) - expect(onPromptDelivered).toHaveBeenCalledOnce() - const persisted = JSON.parse(localStorage.getItem(localStorage.key(0)!) ?? '[]') as { - state: string - }[] - expect(persisted).toMatchObject([{ state: 'dispatching' }]) + it('reads one launch prompt once, however many ask', async () => { + const stream = firstMessageStream(mocks, 'message-1') + const first = awaitStructuredLaunchPromptTaken('session-1', 'message-1') + const second = awaitStructuredLaunchPromptTaken('session-1', 'message-1') + play(await stream, FIRST_START_FAILS.retriedThenTaken) + + await expect(Promise.all([first, second])).resolves.toEqual([true, true]) + expect(mocks.subscribe).toHaveBeenCalledOnce() }) it('drops the previous attempt failure when the launch path sends the message again', async () => { @@ -151,69 +130,4 @@ describe('settleStructuredAgentLaunchPrompt', () => { // Whatever the start's failure (signed out, not installed, the agent exiting as it started), it // ends as the message's rejection; nothing that writes on delivery may run. - it.each(['notSignedIn', 'providerMissing', 'providerExited'])( - 'reports a launch prompt whose start failed (%s) undelivered, after a retried start', - async (kind) => { - const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'review this') - const onPromptDelivered = vi.fn() - const clientMessageId = stagedEntry!.clientMessageId - mocks.call.mockResolvedValue(sendAnswer(clientMessageId)) - const queued = { clientMessageId, ...PENDING, handoverRecorded: true as const } - publishes( - frame('snapshot', queued), - frame('batch', { - ...queued, - startFailure: { - attempts: 1, - reason: 'An account switch is in progress.', - rejection: { kind: 'accountSwitchInProgress' }, - failedAt: 2, - nextAttemptAt: 15_002 - } - }), - frame('batch', { ...queued, dispatchState: 'rejected', rejection: { kind } }) - ) - - await expect( - settleStructuredAgentLaunchPrompt({ - launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), - options: { prompt: 'review this', onPromptDelivered }, - stagedEntry - }) - ).resolves.toEqual({ delivered: false, failureNotified: false }) - expect(onPromptDelivered).not.toHaveBeenCalled() - expect(mocks.unsubscribe).toHaveBeenCalledOnce() - } - ) - - it('counts a pending answer from a host that predates the hand-over record as handed over', async () => { - const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'review this') - mocks.call.mockResolvedValue(sendAnswer(stagedEntry!.clientMessageId, false)) - - await expect( - settleStructuredAgentLaunchPrompt({ - launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), - options: { prompt: 'review this' }, - stagedEntry - }) - ).resolves.toEqual({ delivered: true, failureNotified: false }) - expect(mocks.subscribe).not.toHaveBeenCalled() - }) - - it('reports undelivered when the session stream fails before a verdict', async () => { - const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'review this') - mocks.call.mockResolvedValue(sendAnswer(stagedEntry!.clientMessageId)) - mocks.subscribe.mockImplementation(async (_target, _params, _onEvent, onError) => { - queueMicrotask(() => onError(new Error('stream closed'))) - return { unsubscribe: mocks.unsubscribe } - }) - - await expect( - settleStructuredAgentLaunchPrompt({ - launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), - options: { prompt: 'review this' }, - stagedEntry - }) - ).resolves.toEqual({ delivered: false, failureNotified: false }) - }) }) diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index 46e21bc9bc9b..531ffd44e99f 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -18,8 +18,8 @@ import { import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { createBrowserUuid } from '@/lib/browser-uuid' import { - awaitStructuredLaunchPromptHandover, - structuredLaunchPromptHandover + awaitStructuredLaunchPromptTaken, + structuredLaunchPromptVerdict } from '@/lib/structured-agent-session-launch-prompt-handover' export type StructuredPromptDeliveryResult = { @@ -190,13 +190,18 @@ export function settleStructuredAgentLaunchPrompt(args: { answer = answered }) ) - // Admitted is not delivered: the chat's first message starts its agent, which may fail. A - // queued draft keeps its old meaning; a dispatch the chat's own outbox ran answered elsewhere. + // Admitted is not delivered: the chat's first message starts its agent, which may fail, so + // a pending answer waits for the message's own final state. A queued draft keeps its old + // meaning; a dispatch the chat's own outbox ran answered elsewhere. + const admitted = await dispatch.promise + const verdict = + answer === undefined || answer === 'queued' ? null : structuredLaunchPromptVerdict(answer) const delivered = - (await dispatch.promise) && + admitted && (answer === 'queued' || - (answer !== undefined && structuredLaunchPromptHandover(answer) === 'handed-over') || - (await awaitStructuredLaunchPromptHandover(entry.sessionId, entry.clientMessageId))) + verdict === 'taken' || + (verdict !== 'not-taken' && + (await awaitStructuredLaunchPromptTaken(entry.sessionId, entry.clientMessageId)))) if (delivered) { args.options.onPromptDelivered?.() } From 6922ca6968e1bbc67c4c77d0d9d59c3cc3d922d9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:04:07 -0700 Subject: [PATCH 20/84] fix(native-chat): a Claude chat at rest names no model until one runs Every new chat now sits at rest until its first message, and the at-rest options read named the model catalog's default ("Opus", with its Effort) as the chat's model. Claude's settings or env may run another, so the picker flipped to the real model once the agent started. The live child and the client's own catalog upgrade already refuse to name Claude's default. At rest the host now names the catalog default only for Codex; a Claude chat with no saved model answers an unnamed model, and the client shows an unnamed model as unknown (the neutral "Model" pill, no effort) instead of adding an empty row and naming the listing's default. --- ...-structured-effort-default-at-rest.test.ts | 26 +++++++++++++++++++ .../structured-agent-session-options-read.ts | 9 ++++--- .../structured-agent-session-options.ts | 7 ++++- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/main/claude/claude-structured-effort-default-at-rest.test.ts b/src/main/claude/claude-structured-effort-default-at-rest.test.ts index 0228b1171683..313c8264196a 100644 --- a/src/main/claude/claude-structured-effort-default-at-rest.test.ts +++ b/src/main/claude/claude-structured-effort-default-at-rest.test.ts @@ -282,6 +282,32 @@ describe('Claude effort default at rest', () => { expect(pickerEffort(resting)).toBe('high') }) + // Every new chat sits at rest until its first message. Settings or env may run another model + // than the listing's default, so the picker names none (and offers no effort) until one runs. + it('names no model for a new chat at rest even with a listing, and the picker shows it unknown', async () => { + const store = new AgentModelCatalogStore() + await startChild(store) + + const resting = await readAtRest(store, restingRecord({})) + + expect(resting.current).toEqual({ model: '' }) + const seed = getAgentSessionOptionCatalog('claude')! + const snapshot = structuredAgentSessionOptionSnapshot( + applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('claude', seed), + seed, + resting + ) + ) + const model = snapshot.find((row) => row.id === 'model') + expect(model).toMatchObject({ valueSource: 'unknown' }) + expect(model?.kind.type === 'select' ? model.kind.currentValue : 'named').toBeUndefined() + expect( + model?.kind.type === 'select' ? model.kind.choices.map((c) => c.value) : [] + ).not.toContain('') + expect(snapshot.find((row) => row.id === 'effort')).toBeUndefined() + }) + it('names no model at rest with no pick and no catalog, so the client keeps what the child ran', async () => { // The CLI's default model, reported live; the chat never picked one. const child = await startChild(new AgentModelCatalogStore(), undefined, [], { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts index 106998ccfed5..d77c098b4184 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-options-read.ts @@ -53,11 +53,14 @@ async function readStructuredAgentSessionOptionsAtRest( saved.fastMode === undefined ? null : decodeStructuredAgentSessionOptionValue('fastMode', saved.fastMode) - // An unknown model is one the client already treats as unconfirmed. Only a real listing names the - // account's default; a built-in list's default is a guess, so with none the client keeps its own. + // An unnamed model is one the client shows as unknown. Only a real Codex listing names the model a + // start will run; Claude's settings or env may pick another than its listing's default, so a Claude + // chat no agent has run names none, as its live child and the client's own upgrade never do. const model = saved.model ?? - (catalog.origin === 'unknown' ? undefined : models.find((entry) => entry.isDefault)?.id) ?? + (catalog.origin === 'unknown' || record.provider !== 'codex' + ? undefined + : models.find((entry) => entry.isDefault)?.id) ?? '' // As a live child answers: the pick, else what Claude runs for this model when none is sent. // A live Codex child answers only the effort its thread reported, never the model's default. diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index c397ce4e5fe5..9f9ed358509b 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -79,6 +79,11 @@ export function structuredAgentSessionOptionCatalog( const models: CatalogModel[] = result.models.map((model) => discoveredModel(model, result.fastModeSupport?.supported === true) ) + // An unnamed model is unknown, not the listing's default: the picker names none until it is. + const { defaultModelIsCliDefault: _seedNamesDefault, ...unnamed } = seed + if (!result.current.model) { + return { ...unnamed, models } + } if (!models.some((model) => model.id === result.current.model)) { models.push({ id: result.current.model, @@ -86,7 +91,7 @@ export function structuredAgentSessionOptionCatalog( options: seed.unknownModelOptions ?? [] }) } - return { ...seed, models, defaultModelIsCliDefault: true } + return { ...unnamed, models, defaultModelIsCliDefault: true } } export type StructuredAgentSessionOptionState = { From f50792b653d935dd9c338460f34439f5d35fad2a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:07:03 -0700 Subject: [PATCH 21/84] test(native-chat): type the first-message endings table --- ...omment-resolution-launch-delivery.test.tsx | 40 ++++++++++--------- ...gent-session-launch-prompt-test-support.ts | 2 + 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx index afd94b68a74a..2415820fdb85 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx @@ -29,7 +29,8 @@ import { settleStructuredAgentLaunchPrompt } from '@/lib/structured-agent-sessio import { FIRST_START_FAILS, firstMessageStream, - play + play, + type FirstMessageChange } from '@/lib/structured-agent-session-launch-prompt-test-support' import { clearPendingPRCommentAiAck, @@ -189,25 +190,28 @@ describe('Resolve comments with AI, when the chat starts on its first message', expect(model.pendingCommentResolutionRef.current).toBeNull() }) - it.each([ + const endings: [string, readonly FirstMessageChange[]][] = [ ['rejected after its tries', FIRST_START_FAILS.rejectedAfterTries], ['withdrawn when its chat closes mid-wait', FIRST_START_FAILS.chatClosed], ...['notSignedIn', 'providerMissing', 'providerExited'].map( - (kind) => - [ - `refused at once (${kind})`, - [{ dispatchState: 'rejected' as const, rejection: { kind } }] - ] as const + (kind): [string, readonly FirstMessageChange[]] => [ + `refused at once (${kind})`, + [{ dispatchState: 'rejected', rejection: { kind } }] + ] ) - ])('posts nothing when the first message is %s, and hands the comments back', async (_c, end) => { - const { model, hook } = acknowledgement() - const { host, launched } = resolveCommentsWithAi(hook) - play(await host, end) - - await expect(launched).resolves.toBe(false) - expectNothingPosted(model) - expect(model.pendingCommentResolutionRef.current).toMatchObject({ - reviewContextKey: REVIEW_KEY - }) - }) + ] + it.each(endings)( + 'posts nothing when the first message is %s, and hands the comments back', + async (_c, end) => { + const { model, hook } = acknowledgement() + const { host, launched } = resolveCommentsWithAi(hook) + play(await host, end) + + await expect(launched).resolves.toBe(false) + expectNothingPosted(model) + expect(model.pendingCommentResolutionRef.current).toMatchObject({ + reviewContextKey: REVIEW_KEY + }) + } + ) }) diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt-test-support.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt-test-support.ts index 48b016d07ad7..08ae3cbe3efc 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt-test-support.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt-test-support.ts @@ -10,6 +10,8 @@ export type LaunchPromptClientMock = { call: Mock; subscribe: Mock } type FirstMessage = Omit +export type FirstMessageChange = Partial + const QUEUED: FirstMessage = { fence: 1, payloadFingerprint: 'fingerprint', From 11db6174eff78b189c8f731737492470878afd6a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:11:14 -0700 Subject: [PATCH 22/84] fix(native-chat): "Started an AI agent" toasts wait for the agent to take the launch prompt The fix-checks actions (PR page, PR dialog, check-run details, checks panel), the fix-checks dialog on the PR page, and the commit/push recovery launch said "Started an AI agent" as soon as the chat tab existed. With the agent starting on the chat's first message, that toast could appear for an agent whose start then failed. They now wait for the launch prompt's delivery (the chat's first message reaching its final state) and stay in their starting state until then; a start that fails says nothing extra here, since the failure is shown on the message. --- .../pull-request-page/checks/fix-launch.ts | 2 + .../source-control/ai/recovery-launch.ts | 5 +++ .../src/lib/fix-checks-agent-launch.test.ts | 25 +++++++++++ .../src/lib/fix-checks-agent-launch.ts | 5 ++- .../src/lib/launch-prompt-delivered.test.ts | 20 +++++++++ .../src/lib/launch-prompt-delivered.ts | 17 ++++++++ .../launch-work-item-direct-agent-routing.ts | 5 ++- .../src/lib/launch-work-item-direct-types.ts | 3 ++ .../src/lib/launch-work-item-direct.test.ts | 41 +++++++++++++++++++ .../src/lib/launch-work-item-direct.ts | 7 +++- 10 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 src/renderer/src/lib/launch-prompt-delivered.test.ts create mode 100644 src/renderer/src/lib/launch-prompt-delivered.ts diff --git a/src/renderer/src/components/pull-request-page/checks/fix-launch.ts b/src/renderer/src/components/pull-request-page/checks/fix-launch.ts index 3944540ef66e..7a8329bd7c85 100644 --- a/src/renderer/src/components/pull-request-page/checks/fix-launch.ts +++ b/src/renderer/src/components/pull-request-page/checks/fix-launch.ts @@ -64,6 +64,8 @@ export async function startFixChecksFromDialog(args: { launchSource: 'task_page', telemetrySource: 'sidebar', promptDelivery: 'submit-after-ready', + // The dialog says "Started an AI agent" on true: only once the agent took the prompt. + waitForPromptDelivery: true, agentOverride: args.agent, agentArgs: args.agentArgs, openModalFallback: () => { diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts b/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts index 4a055a613eec..bbfe8a4e876b 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts @@ -2,6 +2,7 @@ import { toast } from 'sonner' import type { AppState } from '@/store' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' +import { launchPromptDelivered } from '@/lib/launch-prompt-delivered' import { getConnectionId } from '@/lib/connection-context' import { planAgentCliArgsSuffix } from '@/lib/tui-agent-startup' import { @@ -173,6 +174,10 @@ export async function launchSourceControlRecoveryAgentWithDefault({ if (result.surface.kind === 'local-terminal') { focusTerminalTabSurface(result.surface.tabId) } + // "Started" only once the agent took the prompt; a failed start is reported on its message. + if (!(await launchPromptDelivered(result))) { + return false + } toast.success(copy.success) return true } diff --git a/src/renderer/src/lib/fix-checks-agent-launch.test.ts b/src/renderer/src/lib/fix-checks-agent-launch.test.ts index 32c636f13f7c..7f22be3c85ba 100644 --- a/src/renderer/src/lib/fix-checks-agent-launch.test.ts +++ b/src/renderer/src/lib/fix-checks-agent-launch.test.ts @@ -367,4 +367,29 @@ describe('startFixChecksAgent', () => { expect(mocks.focusTerminalTabSurface).not.toHaveBeenCalled() }) + + // A structured chat's first message starts its agent: "Started" is said only once it took the + // prompt, so a start that failed (or is still retrying) answers no here. + it.each([ + [true, true], + [false, false] + ])( + 'answers the attached workspace launch by its prompt delivery (delivered: %s)', + async (delivered, started) => { + mocks.launchAgentInNewTab.mockReturnValue({ + surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'session-1' }, + promptDeliveryResult: Promise.resolve({ delivered, failureNotified: false }) + }) + const { startFixChecksAgent } = await import('./fix-checks-agent-launch') + + await expect( + startFixChecksAgent({ + repoId: 'repo-1', + worktreeId: 'wt-1', + basePrompt: 'Fix checks', + launchSource: 'task_page' + }) + ).resolves.toBe(started) + } + ) }) diff --git a/src/renderer/src/lib/fix-checks-agent-launch.ts b/src/renderer/src/lib/fix-checks-agent-launch.ts index 1bfa1b78253f..83460e8b9888 100644 --- a/src/renderer/src/lib/fix-checks-agent-launch.ts +++ b/src/renderer/src/lib/fix-checks-agent-launch.ts @@ -4,6 +4,7 @@ import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { findGithubPrWorkspaceAttachment } from '@/lib/github-work-item-workspace-attachment' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import { launchWorkItemDirect } from '@/lib/launch-work-item-direct' +import { launchPromptDelivered } from '@/lib/launch-prompt-delivered' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { planAgentCliArgsSuffix } from '@/lib/tui-agent-startup' @@ -222,7 +223,8 @@ export async function startFixChecksAgent(args: StartFixChecksAgentArgs): Promis if (result.surface.kind === 'local-terminal') { focusTerminalTabSurface(result.surface.tabId) } - return true + // Callers say "Started an AI agent" on true: only once the agent took the prompt. + return launchPromptDelivered(result) } if (!args.item || !args.openModalFallback) { @@ -246,6 +248,7 @@ export async function startFixChecksAgent(args: StartFixChecksAgentArgs): Promis launchSource: args.launchSource, telemetrySource: args.telemetrySource, promptDelivery: 'submit-after-ready', + waitForPromptDelivery: true, agentArgs: recipe.agentArgs, ...(agentOverride.kind === 'agent' ? { agentOverride: agentOverride.agent } : {}), openModalFallback: args.openModalFallback diff --git a/src/renderer/src/lib/launch-prompt-delivered.test.ts b/src/renderer/src/lib/launch-prompt-delivered.test.ts new file mode 100644 index 000000000000..a2ebffcb7d84 --- /dev/null +++ b/src/renderer/src/lib/launch-prompt-delivered.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { launchPromptDelivered } from './launch-prompt-delivered' + +describe('launchPromptDelivered', () => { + it('counts a launch with no prompt delivery to wait on as started', async () => { + await expect(launchPromptDelivered({})).resolves.toBe(true) + }) + + it('answers by the delivery, and a delivery that failed outright as not delivered', async () => { + await expect( + launchPromptDelivered({ promptDeliveryResult: Promise.resolve({ delivered: true }) }) + ).resolves.toBe(true) + await expect( + launchPromptDelivered({ promptDeliveryResult: Promise.resolve({ delivered: false }) }) + ).resolves.toBe(false) + await expect( + launchPromptDelivered({ promptDeliveryResult: Promise.reject(new Error('lost')) }) + ).resolves.toBe(false) + }) +}) diff --git a/src/renderer/src/lib/launch-prompt-delivered.ts b/src/renderer/src/lib/launch-prompt-delivered.ts new file mode 100644 index 000000000000..19774bf5a077 --- /dev/null +++ b/src/renderer/src/lib/launch-prompt-delivered.ts @@ -0,0 +1,17 @@ +// Whether a launch's prompt reached its agent, for a caller that says "Started an AI agent" only +// then. A structured chat's first message starts its agent, so the tab existing proves nothing; a +// launch with no prompt delivery to wait on counts as started when its surface exists. + +type LaunchWithPromptDelivery = { + promptDeliveryResult?: Promise<{ delivered: boolean }> +} + +export async function launchPromptDelivered(launch: LaunchWithPromptDelivery): Promise { + if (!launch.promptDeliveryResult) { + return true + } + return launch.promptDeliveryResult.then( + (result) => result.delivered, + () => false + ) +} diff --git a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts index cbf080f12565..110a4d74356b 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts @@ -7,6 +7,7 @@ import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab' +import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-session-launch-prompt' export function buildDirectWorkItemStartup(args: { agent: TuiAgent | null @@ -86,6 +87,7 @@ export function beginDirectWorkItemStructuredLaunch(args: { completed: boolean structuredLaunch: boolean primaryTabId: string | null + promptDeliveryResult?: Promise } { const { plan } = args const notLaunched = (structuredLaunch: boolean) => ({ @@ -107,6 +109,7 @@ export function beginDirectWorkItemStructuredLaunch(args: { return { completed: true, structuredLaunch: true, - primaryTabId: launch.tab.id + primaryTabId: launch.tab.id, + ...(launch.promptDeliveryResult ? { promptDeliveryResult: launch.promptDeliveryResult } : {}) } } diff --git a/src/renderer/src/lib/launch-work-item-direct-types.ts b/src/renderer/src/lib/launch-work-item-direct-types.ts index 4d68b4d36c33..a2b320899cff 100644 --- a/src/renderer/src/lib/launch-work-item-direct-types.ts +++ b/src/renderer/src/lib/launch-work-item-direct-types.ts @@ -32,4 +32,7 @@ export type LaunchWorkItemDirectArgs = { agentArgs?: string | null promptDelivery?: 'draft' | 'submit-after-ready' launchPlatform?: NodeJS.Platform + /** Answer only once a structured chat's agent took the prompt, for a caller that then says it + * started; otherwise the launch counts once its chat tab exists. */ + waitForPromptDelivery?: boolean } diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index 5b6686774bc0..5e8efa3938aa 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -586,6 +586,47 @@ describe('launchWorkItemDirect', () => { expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled() }) + // A caller that then says "Started an AI agent" waits for the chat's agent to take the prompt; + // others count the launch once its chat tab exists. + it.each([ + [true, false, false], + [true, true, true], + [false, false, true] + ])( + 'answers a structured launch by its prompt delivery when asked to wait (wait: %s, delivered: %s)', + async (waitForPromptDelivery, delivered, answer) => { + mocks.ensureDetectedAgents.mockResolvedValue(['claude']) + vi.mocked(beginDirectWorkItemStructuredLaunch).mockImplementationOnce((launch) => { + launch.beforeOpen('session-1') + return { + completed: true, + structuredLaunch: true, + primaryTabId: 'chat-tab', + promptDeliveryResult: Promise.resolve({ delivered, failureNotified: false }) + } + }) + const { launchWorkItemDirect } = await import('./launch-work-item-direct') + + await expect( + launchWorkItemDirect({ + repoId: 'repo-1', + launchSource: 'task_page', + openModalFallback: vi.fn(), + agentOverride: 'claude', + promptDelivery: 'submit-after-ready', + waitForPromptDelivery, + item: { + type: 'pr', + number: 7, + title: 'Review this PR', + url: 'https://github.com/acme/repo/pull/7', + pasteContent: 'Fix the checks.' + } + }) + ).resolves.toBe(answer) + } + ) + it('uses remote cursor-agent detection and paste launch for SSH repos', async () => { mocks.store.repos = [ { diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index 6b027e8303e3..c913a0da628c 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -31,6 +31,7 @@ import { resolveDirectSetupDecision } from '@/lib/launch-work-item-direct-preflight' import type { LaunchWorkItemDirectArgs } from '@/lib/launch-work-item-direct-types' +import { launchPromptDelivered } from '@/lib/launch-prompt-delivered' import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' @@ -166,6 +167,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom let draftLaunchedNatively = false let plan: AgentSessionLaunchPlan | null = null let structuredLaunchCompleted = false + let structuredLaunch: ReturnType | null = null const draftContent = await getDirectWorkItemDraftContent(item, repoConnectionId) let startupPlanFailed = false try { @@ -265,6 +267,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom return false } structuredLaunchCompleted = structuredResult.completed + structuredLaunch = structuredResult primaryTabId = structuredResult.completed ? structuredResult.primaryTabId : activation.primaryTabId @@ -277,7 +280,9 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom store.setSidebarOpen(true) if (structuredLaunchCompleted) { - return true + return args.waitForPromptDelivery && structuredLaunch + ? launchPromptDelivered(structuredLaunch) + : true } if (startupPlanFailed) { From 9b48e2aef0774795a94838cb4e9b86bfbcad2688 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:12:36 -0700 Subject: [PATCH 23/84] test(native-chat): closing a chat publishes its waiting first message's rejection to a reader Pins what ends a launch prompt's read when its chat closes mid-wait: the host withdraws the queued first message, keeping the start failure it waited out, and publishes that rejection to a subscriber. The renderer's closed-chat scenario now uses that same rejection. --- ...sion-close-withdraws-first-message.test.ts | 108 ++++++++++++++++++ ...gent-session-launch-prompt-test-support.ts | 8 +- 2 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-close-withdraws-first-message.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-withdraws-first-message.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-withdraws-first-message.test.ts new file mode 100644 index 000000000000..b2c2ecd09b00 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-close-withdraws-first-message.test.ts @@ -0,0 +1,108 @@ +// A launch prompt's caller reads the chat's first message to its final state. Closing the chat +// while that message waits out a retried start must end the read: the host withdraws the message +// and publishes that rejection, so the reader is never left waiting on a chat that is gone. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { AgentSessionPreSpawnError } from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' + +let root: string +let host: StructuredAgentSessionHost + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-close-withdraws-first-message-')) + host = new StructuredAgentSessionHost({ + store: await openTestAgentSessionRecordStore(root), + adapter: { + supportsCreate: () => true, + // A start refused before it ran, by a situation that clears on its own: retried later. + acquire: vi.fn(async () => { + throw new AgentSessionPreSpawnError(new Error('switching'), { + reason: 'accountSwitchInProgress' + }) + }), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + }, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + now: () => NOW + }) +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +it('rejects the waiting first message, and its reader sees the rejection', async () => { + const created = await host.create({ callerKey: 'client-1' }, hostTestAttachParams(null)) + if (!created.ok) { + throw new Error('create refused') + } + const body = hostTestMessage('fix the checks') + const clientMessageId = hostTestOperationId() + await host.send( + { callerKey: 'client-1' }, + { + envelope: { + sessionId: SESSION, + clientOperationId: clientMessageId, + expectedRuntimeFence: created.fence, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body } + }) + }, + body + } + ) + await vi.waitFor(async () => + expect( + (await host.journalSnapshot(SESSION)).submissions[0]?.startFailure?.nextAttemptAt + ).toBeDefined() + ) + const events: AgentSessionSubscribeEvent[] = [] + await host.subscribe({ id: 'launch-prompt', sessionId: SESSION, emit: (e) => events.push(e) }) + + await host.close(SESSION, 'user-close') + + const seen = events.flatMap((event) => + event.type === 'batch' + ? event.batch.submissions + : event.type === 'snapshot' || event.type === 'reset' + ? event.page.submissions + : [] + ) + const ended = events.some((event) => event.type === 'end') + // Withdrawn with the start failure it was waiting out, so it reads as that failure. + const rejected = seen.some( + (entry) => + entry.clientMessageId === clientMessageId && + entry.dispatchState === 'rejected' && + entry.rejection?.kind === 'accountSwitchInProgress' + ) + expect(ended || rejected).toBe(true) + expect( + (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) + ).toMatchObject({ dispatchState: 'rejected' }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt-test-support.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt-test-support.ts index 08ae3cbe3efc..e06a09643c78 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt-test-support.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt-test-support.ts @@ -48,8 +48,12 @@ export const FIRST_START_FAILS = { RETRYING(3), { dispatchState: 'rejected', rejection: { kind: 'accountSwitchInProgress' } } ], - /** The person closes the chat while it waits: the host withdraws the message. */ - chatClosed: [RETRYING(1), { dispatchState: 'rejected', rejection: { kind: 'chatClosed' } }] + /** The person closes the chat while it waits: the host withdraws the message, keeping the start + * failure it waited out (structured-agent-session-close-withdraws-first-message.test.ts). */ + chatClosed: [ + RETRYING(1), + { dispatchState: 'rejected', rejection: { kind: 'accountSwitchInProgress' } } + ] } satisfies Record[]> export type FirstMessageStream = { From 1c49e81e64d4a08a8dc0de341924788a4167d06f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:13:51 -0700 Subject: [PATCH 24/84] fix(native-chat): a refounded record keeps its lease's fence floor and waits for reconciliation Founding again a record an older host's failed start left replaced its lease with a fresh one that copied only the fence. That lost the floor a recovered copy of the records sets (so a fence a lost write may hold is never granted again) and skipped the reconcile gate. The create now founds over the existing lease: the fence moves on through the one mint, past any floor, and a lease this host has not yet adjudicated since load is refused as execution_owner_reconciling. --- .../agent-session-at-rest-create.test.ts | 31 +++++++++++++++-- .../runtime/agent-session-at-rest-create.ts | 33 ++++++++++++++----- ...d-agent-session-integration-create.test.ts | 4 +-- 3 files changed, 55 insertions(+), 13 deletions(-) diff --git a/src/main/runtime/agent-session-at-rest-create.test.ts b/src/main/runtime/agent-session-at-rest-create.test.ts index fcf5c2b9540b..5e596d843aba 100644 --- a/src/main/runtime/agent-session-at-rest-create.test.ts +++ b/src/main/runtime/agent-session-at-rest-create.test.ts @@ -199,7 +199,7 @@ function failedCreateFromOlderHost( } describe('a create over a record an older host left after its start failed', () => { - it('founds it again at rest, at its own fence, keeping its name', () => { + it('founds it again at rest, at the next fence, keeping its name', () => { const existing = failedCreateFromOlderHost() const state = storeState([existing]) @@ -212,13 +212,40 @@ describe('a create over a record an older host left after its start failed', () createdAt: existing.createdAt }) expect(record.lease).toMatchObject({ - runtimeFence: 2, + runtimeFence: 3, claimStatus: 'released', handoffStage: null, deathEvidence: null }) }) + it('keeps the floor a recovered copy set, and mints the fence past it', () => { + const exited = failedCreateFromOlderHost().lease + const state = storeState([ + failedCreateFromOlderHost({ lease: { ...exited, minimumNextFence: 9 } }) + ]) + + const { record } = commitAgentSessionAtRestCreate(state, createRequest()) + + expect(record.lease).toMatchObject({ runtimeFence: 9, minimumNextFence: 9 }) + }) + + it('refuses while this host has not yet adjudicated the lease', () => { + const exited = failedCreateFromOlderHost().lease + const state = storeState([ + failedCreateFromOlderHost({ lease: { ...exited, unreconciled: true } }) + ]) + + expect(() => commitAgentSessionAtRestCreate(state, createRequest())).toThrow( + expect.objectContaining({ + refusal: expect.objectContaining({ + code: 'execution_owner_reconciling', + details: { reason: 'hostReconciling' } + }) + }) + ) + }) + it('refuses one that bound a conversation, may still run, or is another identity', () => { const exited = failedCreateFromOlderHost().lease const records = [ diff --git a/src/main/runtime/agent-session-at-rest-create.ts b/src/main/runtime/agent-session-at-rest-create.ts index bd2ee7d0e6c3..1fcaf76da97a 100644 --- a/src/main/runtime/agent-session-at-rest-create.ts +++ b/src/main/runtime/agent-session-at-rest-create.ts @@ -8,6 +8,7 @@ import { type AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' import { agentSessionLeaseOwnerVerdict } from '../../shared/agent-session-lease-adjudication' +import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' import { agentSessionExecutionLocationsEqual, isAgentSessionOptions, @@ -84,18 +85,14 @@ export function commitAgentSessionAtRestCreate( if (existing && !refoundable(existing, request)) { throw agentSessionRefusalError('agent_session_conflict', { reason: 'sessionExists' }) } + if (existing?.lease.unreconciled) { + // This host has not adjudicated the lease since it loaded it: nothing proves its exit yet. + throw agentSessionRefusalError('execution_owner_reconciling', { reason: 'hostReconciling' }) + } assertAdoptedConversationUnowned(state, request) assertReservedTabUnheld(state, request) const founded = foundAgentSessionRecord(request, request, request.adoptedHandleLink) - // Refounded keeps what the chat already has (its name) and its fence: a fence never moves back. - const record = existing - ? { - ...existing, - ...founded, - createdAt: existing.createdAt, - lease: { ...founded.lease, runtimeFence: existing.lease.runtimeFence } - } - : founded + const record = existing ? refounded(existing, founded) : founded state.records.set(record.sessionId, record) return recordOperationRow(state, record, request, false) } @@ -156,6 +153,24 @@ function refoundable( ) } +/** + * Founded again over the lease already there, so what that lease carries holds: its fence moves + * on through the one mint (never back, never onto a floor a recovered copy set), and the chat keeps + * what it already has, such as its name. + */ +function refounded(existing: AgentSessionRecord, founded: AgentSessionRecord): AgentSessionRecord { + return { + ...existing, + ...founded, + createdAt: existing.createdAt, + lease: { + ...existing.lease, + ...founded.lease, + runtimeFence: nextAgentSessionFence(existing.lease) + } + } +} + /** * Refuse an adoption whose conversation ANOTHER record already holds. * diff --git a/src/main/runtime/structured-agent-session-integration-create.test.ts b/src/main/runtime/structured-agent-session-integration-create.test.ts index 5dcd7a2ff06f..011db656d72f 100644 --- a/src/main/runtime/structured-agent-session-integration-create.test.ts +++ b/src/main/runtime/structured-agent-session-integration-create.test.ts @@ -170,8 +170,8 @@ describe('creating a structured codex chat over agentSession.*', () => { 'agentSession.create', harness.createIntentParams() ) - // Founded again at rest, at the fence the failed start left: a fence never moves back. - expect(created.fence).toBe(left?.lease.runtimeFence) + // Founded again at rest, one fence past the failed start's: a fence never moves back. + expect(created.fence).toBe((left?.lease.runtimeFence ?? 0) + 1) await send(created.fence, 'signed in now') await startedTurn() }) From c48362595639e79e24a61377ccd738e5c3143258 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:15:45 -0700 Subject: [PATCH 25/84] fix(orchestration): a structured worker's preamble waits only as long as the worker start's timeout allows The preamble waited a fixed 60 s for the worker's agent whatever the start's own --timeout-ms, so with a short timeout or a setup wait the CLI's transport deadline (the timeout plus a fixed grace) could fire before the outcome_unknown receipt arrived. The wait is now the readiness wait capped by what the start's timeout leaves, plus the allowance a terminal worker's turn observation gets, which keeps the answer inside the CLI's grace. --- .../deliver-worker-dispatch-preamble.ts | 5 ++- .../worker/local-worker-start.ts | 2 + .../worker/worker-start-budgets.test.ts | 26 +++++++++++- .../worker-start-readiness-settlement.test.ts | 40 +++++++++++++++++-- .../worker-start-readiness-settlement.ts | 10 ++++- src/shared/orchestration-timing-budgets.ts | 14 +++++++ 6 files changed, 90 insertions(+), 7 deletions(-) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts index 2e66958af737..2f22c25251f1 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/deliver-worker-dispatch-preamble.ts @@ -32,6 +32,8 @@ export async function deliverWorkerDispatchPreamble(args: { coordinatorHandle: string devMode: boolean | undefined requestId: string + /** How long a structured preamble waits for its agent. */ + preambleBudgetMs?: number }): Promise<{ prompt?: RuntimeTerminalSend['prompt'] structuredTurnStart?: WorkerTurnStartObservation @@ -55,7 +57,8 @@ export async function deliverWorkerDispatchPreamble(args: { host: structuredSession.host, sessionId: structuredSession.identity.sessionId, dispatchId: args.dispatchId, - preamble + preamble, + ...(args.preambleBudgetMs === undefined ? {} : { budgetMs: args.preambleBudgetMs }) }) return { structuredTurnStart: structuredPreambleTurnStart(delivery) } } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index a68ec621699b..07b56e3b9c44 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -50,6 +50,7 @@ export async function startLocalWorker(args: { mode: WorkerStartModeReceipt }): Promise { const { params, runtime, db, run, coordinator, callerSession, existingTask } = args + const startedAtMs = Date.now() const { orchestrationMutation } = args const coordinatorPane = coordinator?.paneKey ?? null const requestedWorktree = params.worktree ?? 'current' @@ -242,6 +243,7 @@ export async function startLocalWorker(args: { launchReceipt: launch.receipt, mode, timeoutMs: params.timeoutMs ?? 60_000, + startedAtMs, effects, terminalRevealWarning: placed.warning, onStage: (stage) => { diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-budgets.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-budgets.test.ts index ae98c4793ad4..1fafcd906504 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-budgets.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-budgets.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest' import { MAX_TIMER_DELAY_MS } from '../../../../../../shared/timer-delay' import { + AGENT_PROMPT_EFFECT_TIMEOUT_MS, ORCHESTRATION_READINESS_TIMEOUT_MS, - ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS + ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS, + resolveStructuredWorkerPreambleBudgetMs } from '../../../../../../shared/orchestration-timing-budgets' import { resolveFederatedWorkerStartBudgets } from './worker-start-budgets' @@ -32,3 +34,25 @@ describe('worker-start transport budgets', () => { ) }) }) + +describe('a structured worker preamble budget', () => { + it('always ends inside the client grace a worker start gets, and never past the readiness wait', () => { + for (const timeoutMs of [1_000, 10_000, 60_000, 600_000]) { + for (const spentMs of [0, timeoutMs / 2, timeoutMs, timeoutMs + 40_000]) { + const budget = resolveStructuredWorkerPreambleBudgetMs({ + startedAtMs: 0, + timeoutMs, + nowMs: spentMs + }) + expect(budget).toBeGreaterThanOrEqual(0) + expect(budget).toBeLessThanOrEqual(ORCHESTRATION_READINESS_TIMEOUT_MS) + expect(spentMs + budget).toBeLessThanOrEqual( + Math.max(spentMs, timeoutMs + AGENT_PROMPT_EFFECT_TIMEOUT_MS) + ) + expect(AGENT_PROMPT_EFFECT_TIMEOUT_MS).toBeLessThan( + ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS + ) + } + } + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts index ceeae2710d4d..24593f612de8 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.test.ts @@ -8,7 +8,13 @@ vi.mock('./worker-topology', async (importOriginal) => ({ const { deliverAndSettleWorkerStartReadiness } = await import('./worker-start-readiness-settlement') -function settle(delivered: 'accepted' | undefined) { +function settle( + delivered: 'accepted' | undefined, + start: { timeoutMs: number; startedAtMs: number } = { + timeoutMs: 60_000, + startedAtMs: Date.now() + } +) { const db = { getWorkerDispatch: () => ({ state: 'starting' }), markWorkerStartUnknown: vi.fn(() => ({ @@ -24,10 +30,11 @@ function settle(delivered: 'accepted' | undefined) { value: { clientMessageId: 'c1', submission: { dispatchState: 'pending', reason: null } } }), // undefined: the worker's agent was still starting when the wait ran out. - waitForSendSettlement: async () => + waitForSendSettlement: vi.fn(async (..._args: unknown[]) => delivered ? { value: { clientMessageId: 'c1', submission: { dispatchState: delivered } } } : undefined + ) } const args = { runtime: { @@ -48,14 +55,14 @@ function settle(delivered: 'accepted' | undefined) { setupReceipt: {}, launchReceipt: {}, mode: {}, - timeoutMs: 60_000, + ...start, effects: [], terminalRevealWarning: undefined, onStage: () => {} } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fakes implement exactly the runtime, db and host members this settlement reaches. const receipt = deliverAndSettleWorkerStartReadiness(args as never) - return { db, receipt } + return { db, receipt, host } } describe('a structured worker whose agent outlasts the preamble wait', () => { @@ -86,4 +93,29 @@ describe('a structured worker whose agent outlasts the preamble wait', () => { await expect(receipt).resolves.toMatchObject({ state: 'ready', turnStart: 'observed' }) expect(db.markWorkerStartUnknown).not.toHaveBeenCalled() }) + + // The CLI gives a worker start its own timeout plus a fixed grace; a short timeout must not + // leave the preamble waiting past it, or the CLI times out before the receipt arrives. + it.each([ + [60_000, 0, 60_000], + [10_000, 0, 40_000], + [10_000, 25_000, 15_000], + [10_000, 45_000, 0] + ])( + 'waits for the agent within the start timeout %i ms less what %i ms already spent', + async (timeoutMs, spentMs, budgetMs) => { + vi.useFakeTimers({ toFake: ['Date'] }) + try { + vi.setSystemTime(1_000_000) + const { host, receipt } = settle(undefined, { + timeoutMs, + startedAtMs: 1_000_000 - spentMs + }) + await receipt + expect(host.waitForSendSettlement.mock.calls[0]?.[2]).toMatchObject({ budgetMs }) + } finally { + vi.useRealTimers() + } + } + ) }) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts index 98ddbecd6030..d99f0577dcd4 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-readiness-settlement.ts @@ -2,6 +2,7 @@ import type { OrcaRuntimeService } from '../../../../orca-runtime' import type { OrchestrationDb } from '../../../../orchestration/db' import type { RunRow, TaskRow } from '../../../../orchestration/types' import type { WorkerStartModeReceipt } from '../../orchestration-worker-start-mode' +import { resolveStructuredWorkerPreambleBudgetMs } from '../../../../../../shared/orchestration-timing-budgets' import { deliverWorkerDispatchPreamble } from './deliver-worker-dispatch-preamble' import type { OrchestrationWorkerLaunchReceipt } from './worker-launch-preferences' import { @@ -38,6 +39,8 @@ export async function deliverAndSettleWorkerStartReadiness(args: { launchReceipt: OrchestrationWorkerLaunchReceipt mode: WorkerStartModeReceipt timeoutMs: number + /** When the worker start began, so the preamble's wait ends inside the caller's own timeout. */ + startedAtMs: number effects: WorkerEffect[] terminalRevealWarning: string | undefined /** Keeps the caller's failure receipt naming the stage that actually failed. */ @@ -56,7 +59,12 @@ export async function deliverAndSettleWorkerStartReadiness(args: { taskSpec: task.spec, coordinatorHandle: args.coordinatorHandle, devMode: args.devMode, - requestId: args.requestId + requestId: args.requestId, + preambleBudgetMs: resolveStructuredWorkerPreambleBudgetMs({ + startedAtMs: args.startedAtMs, + timeoutMs: args.timeoutMs, + nowMs: Date.now() + }) }) effects.push({ kind: 'dispatch_input', diff --git a/src/shared/orchestration-timing-budgets.ts b/src/shared/orchestration-timing-budgets.ts index 26e3e7956001..acd464ce6f50 100644 --- a/src/shared/orchestration-timing-budgets.ts +++ b/src/shared/orchestration-timing-budgets.ts @@ -13,6 +13,20 @@ export function resolveWorkerStartReadinessTimeoutMs(timeoutMs: number | undefin : ORCHESTRATION_READINESS_TIMEOUT_MS } +/** + * How long a structured worker's preamble may wait for its agent: up to the readiness wait, but + * no longer than the worker start's own timeout leaves plus the allowance a terminal worker's turn + * observation gets, so the host answers inside the CLI's worker-start grace. + */ +export function resolveStructuredWorkerPreambleBudgetMs(args: { + startedAtMs: number + timeoutMs: number + nowMs: number +}): number { + const left = args.startedAtMs + args.timeoutMs + AGENT_PROMPT_EFFECT_TIMEOUT_MS - args.nowMs + return Math.min(ORCHESTRATION_READINESS_TIMEOUT_MS, Math.max(0, left)) +} + export function resolveFederationAttachTimeoutMs( readinessTimeoutMs = ORCHESTRATION_READINESS_TIMEOUT_MS ): number { From e98777e7881933ac7e6871ab5e73961d101aa15f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:16:16 -0700 Subject: [PATCH 26/84] test(native-chat): the launch-prompt ordering test creates at rest and sends at the create's fence It started the agent before sending and sent at the post-start fence, which is not what production does: the create starts nothing and the launch prompt, sent at the create's fence 1, is what starts the agent. It now creates through host.create and sends at that fence, and its header and title say the first send's start publishes the session before the delivery loop dispatches. Also corrects the settlement wait's doc for throughStartRetries. --- ...agent-session-launch-send-after-create.test.ts | 15 ++++++++++----- .../structured-agent-session-send-settlement.ts | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) 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 index d35ec5c9a37a..a017c81251a8 100644 --- 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 @@ -1,4 +1,6 @@ -// Where a launch prompt sent the instant `attach` resolves reaches the adapter. +// Where a launch prompt sent the instant the create resolves reaches the adapter. The create +// starts nothing: that send, at the create's own fence, is what starts the agent, and the delivery +// loop dispatches it only after the start's `acquire` returns. // // Both shipped adapters look the session up in a live map and throw when it is absent // (`claude-structured-session-adapter.ts:331-337`, `codex-structured-session-state.ts`'s @@ -15,8 +17,7 @@ import { CALLER, hostTestState } from './structured-agent-session-host-test-harness' -import { HOST_TEST_NOW as NOW } from './structured-agent-session-host-test-data' -import { attachForTests } from './structured-agent-session-attach-test-support' +import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION } from './structured-agent-session-host-test-data' let host: StructuredAgentSessionHost @@ -46,7 +47,7 @@ async function launchAndDeliver(): Promise<{ dispatchState: string }> { const send = vi.spyOn(host, 'send') - const created = await attachForTests(host, CALLER, attachParams()) + const created = await host.create(CALLER, attachParams()) if (!created.ok) { throw new Error(`expected a create, got ${created.refusal.code}`) } @@ -54,6 +55,7 @@ async function launchAndDeliver(): Promise<{ host, caller: CALLER, sessionId: created.value.sessionId, + // The create's own fence, as `agentLaunchSurfaceFactory` sends it. fence: created.value.fence, text: 'fix the failing test' }) @@ -70,6 +72,7 @@ async function launchAndDeliver(): Promise<{ )?.dispatchState ?? 'missing' expect(dispatchState).not.toBe('pending') }) + expect(created.value.fence).toBe(1) return { messageId, dispatchState } } @@ -81,7 +84,7 @@ beforeEach(() => { }) describe('a launch prompt sent the instant the create resolves', () => { - it('reaches a live provider, because attach returns only after acquire published it', async () => { + it("reaches a live provider, because the first send's start publishes the session before it dispatches", async () => { modelAdapterLiveness(true) await expect(launchAndDeliver()).resolves.toEqual({ @@ -89,6 +92,8 @@ describe('a launch prompt sent the instant the create resolves', () => { dispatchState: 'accepted' }) expect(hostTestState().dispatch).toHaveBeenCalledTimes(1) + // Sent at the create's fence 1; its start moved the record to 2. + expect(hostTestState().store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence).toBe(2) }) // Positive control: the assertion above is only evidence if this arm can fail, and it names diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts index 24251e3a91a7..5310f1d55451 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts @@ -20,7 +20,7 @@ type SendSettlement = SettledSend | 'pending' | 'missing' /** What ends a wait: the provider's answer, the host handing the message over, or either that or * the message waiting behind a running command, which hands nothing over until it ends. A failed - * start recorded on the message ends each: the host answers it now, while it waits for its next try. */ + * start recorded on the message ends each too, unless the wait runs `throughStartRetries`. */ export type SendSettlementPoint = 'answered' | 'handed-over' | 'handed-over-or-behind-command' export type SendSettlementWaitOptions = { From dac6af5bdd9a71976532d81cc6d90c4cd3faf9d5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:20:05 -0700 Subject: [PATCH 27/84] test(native-chat): start existing chats with startAgentForTests instead of fence args attachForTests ignores attachForTests reads a non-null fence only as "the record exists" and starts the agent at the record's own fence, so the literal fences these tests passed (2, 13, 14, a fence read beforehand) were misleading. They now call startAgentForTests with the session id. --- ...tured-agent-session-crash-mid-start.test.ts | 3 +-- ...-session-failed-create-sink-release.test.ts | 6 ++---- ...ured-agent-session-first-start-span.test.ts | 6 ++---- ...agent-session-legacy-handoff-record.test.ts | 5 ++--- ...ctured-agent-session-recovery-exits.test.ts | 4 ++-- ...ructured-agent-session-repeat-press.test.ts | 5 ++--- ...nt-session-wedged-profile-migration.test.ts | 18 ++++++++---------- 7 files changed, 19 insertions(+), 28 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts index 308b67bdfa37..33fde55c0d91 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts @@ -147,8 +147,7 @@ describe('a host that dies while its Codex child is starting', () => { deathEvidence: { kind: 'pid-absent' } }) // What the next send's delivery does: start at the record's current fence. - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null - expect(await attachForTests(relaunched, CALLER, hostTestAttachParams(fence))).toMatchObject({ + expect(await startAgentForTests(relaunched, SESSION)).toMatchObject({ ok: true }) expect(restarted.connections).toHaveLength(1) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts index fe82d9611d09..e3fe1cb9eb11 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts @@ -110,19 +110,17 @@ describe('a first start that fails after its child wrote through the unbound sin fence: exitedFence, acquisitionGeneration: `generation-${exitedFence}` }) - const releasedFence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 acquire.mockImplementationOnce(async ({ events }) => { events?.setActivity?.(null) throw cause }) // The session stays indexed across this failure: it is a resume, not a create. - expect(await attachForTests(host, CALLER, hostTestAttachParams(releasedFence))).toMatchObject({ + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: false }) - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? 0 - await expect(attachForTests(host, CALLER, hostTestAttachParams(fence))).resolves.toMatchObject({ + await expect(startAgentForTests(host, SESSION)).resolves.toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledTimes(3) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-first-start-span.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-first-start-span.test.ts index d6b03e095da5..ca8e9e299992 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-first-start-span.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-first-start-span.test.ts @@ -16,7 +16,7 @@ import { HOST_TEST_THREAD as THREAD, hostTestAttachParams } from './structured-agent-session-host-test-data' -import { attachForTests, startAgentForTests } from './structured-agent-session-attach-test-support' +import { startAgentForTests } from './structured-agent-session-attach-test-support' let root: string let host: StructuredAgentSessionHost @@ -73,9 +73,7 @@ it('wraps the first start, and its retry after a failure, but no start once an a // Put to rest and started again, as the next message after the idle sweep does. await host.collaboratorsForTests().lifetime.stopAgent(SESSION, 'evict') - expect( - await attachForTests(host, { callerKey: 'client-1' }, hostTestAttachParams(2)) - ).toMatchObject({ ok: true }) + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledTimes(3) expect(span).toHaveBeenCalledTimes(2) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts index 97be01dbb8bd..cb95de5978d4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts @@ -24,7 +24,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { attachForTests } from './structured-agent-session-attach-test-support' +import { attachForTests, startAgentForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } @@ -263,8 +263,7 @@ describe('a record an older build left mid terminal handoff', () => { "Codex couldn't restart. This chat is still open in a terminal agent. Quit that agent to continue the chat here." } }) - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null - expect(await attachForTests(host, CALLER, hostTestAttachParams(fence))).toMatchObject({ + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: false, refusal: { code: 'agent_session_conflict', message: quitTerminal } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts index 8de888188643..207391b987cc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts @@ -150,7 +150,7 @@ describe('recovery exits', () => { await reopenStore() openHost({ mintSpawnToken: () => 'spawn-b' }) - expect(await attachForTests(host, CALLER, hostTestAttachParams(2))).toMatchObject({ ok: true }) + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledTimes(2) }) @@ -167,7 +167,7 @@ describe('recovery exits', () => { mintSpawnToken: () => 'spawn-b', probeOwner: async () => ({ outcome: 'pid-absent' }) }) - expect(await attachForTests(host, CALLER, hostTestAttachParams(2))).toMatchObject({ + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: false, refusal: { code: 'agent_session_ownership_unknown', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts index 7e741a686a5a..1d5b1df22236 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-repeat-press.test.ts @@ -22,7 +22,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' -import { attachForTests } from './structured-agent-session-attach-test-support' +import { attachForTests, startAgentForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } @@ -201,8 +201,7 @@ describe('a press sent again', () => { expect(refused.ok ? null : agentSessionRefusalOperationState(refused.refusal.code)).toBe( 'pending-admission' ) - const fence = store.getRecord(SESSION)!.lease.runtimeFence - expect(await attachForTests(host, CALLER, hostTestAttachParams(fence))).toMatchObject({ + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: true }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 2b241ffe80e3..7825f3d32159 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -50,7 +50,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { attachForTests } from './structured-agent-session-attach-test-support' +import { attachForTests, startAgentForTests } from './structured-agent-session-attach-test-support' const CALLER = { callerKey: 'client-1' } const DEAD_OWNER: AgentSessionProcessIdentity = { @@ -371,7 +371,7 @@ describe('already-wedged profiles become usable on load', () => { // The start adjudicates the dead owner first, which moves the fence, then reserves at the // fence it reads from the record. - expect(await attachForTests(host, CALLER, hostTestAttachParams(13))).toMatchObject({ ok: true }) + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledOnce() expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null) @@ -409,7 +409,7 @@ describe('already-wedged profiles become usable on load', () => { await seedRunningTurn() openHost() - expect(await attachForTests(host, CALLER, hostTestAttachParams(13))).toMatchObject({ ok: true }) + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledOnce() expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null) @@ -502,7 +502,7 @@ describe('already-wedged profiles become usable on load', () => { ownerProcess: null, deathEvidence: null }) - expect(await attachForTests(host, CALLER, hostTestAttachParams(14))).toMatchObject({ ok: true }) + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledOnce() }) @@ -526,7 +526,7 @@ describe('already-wedged profiles become usable on load', () => { runtimeFence: 14, deathEvidence: null }) - expect(await attachForTests(host, CALLER, hostTestAttachParams(14))).toMatchObject({ + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledOnce() @@ -540,7 +540,7 @@ describe('already-wedged profiles become usable on load', () => { await seedRunningTurn() openHost() - expect(await attachForTests(host, CALLER, hostTestAttachParams(13))).toMatchObject({ ok: true }) + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: true }) expect(acquire).toHaveBeenCalledOnce() expect(turnLifecycle('turn-1')).toEqual({ @@ -692,8 +692,7 @@ describe('already-wedged profiles become usable on load', () => { await host.restoreReadableSessions() expect(order).toEqual([]) expect(scan).not.toHaveBeenCalled() - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null - expect(await attachForTests(host, CALLER, hostTestAttachParams(fence))).toMatchObject({ + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: true }) @@ -714,8 +713,7 @@ describe('already-wedged profiles become usable on load', () => { await host.restoreReadableSessions() // A terminal agent keeps its transport across a restart, so an unanswered probe is not a way in. - const fence = store.getRecord(SESSION)?.lease.runtimeFence ?? null - expect(await attachForTests(host, CALLER, hostTestAttachParams(fence))).toMatchObject({ + expect(await startAgentForTests(host, SESSION)).toMatchObject({ ok: false, refusal: { code: 'agent_session_conflict' } }) From c4a87d5037d5c9468c29d17d36112477d8a7646b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:24:43 -0700 Subject: [PATCH 28/84] fix(native-chat): a chat at rest with no model listing keeps the static model seed A Codex chat at rest before any listing exists (its account's first chat, a WSL record, a failed catalog read) is answered with no models and no model. After the Claude naming fix the client turned that into an empty catalog, so the composer had no model picker until the first start. An empty listing is an unknown one, as the host's catalog contract says: the client now keeps its static seed's models and naming. --- .../structured-agent-session-options.test.ts | 18 ++++++++++++++++++ src/shared/structured-agent-session-options.ts | 6 ++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/shared/structured-agent-session-options.test.ts b/src/shared/structured-agent-session-options.test.ts index a879918be6bb..45a6714809bb 100644 --- a/src/shared/structured-agent-session-options.test.ts +++ b/src/shared/structured-agent-session-options.test.ts @@ -92,6 +92,24 @@ describe('structured agent session options', () => { expect(model.kind.type === 'select' ? model.kind.currentValue : null).toBe('persisted-unknown') }) + // A Codex chat at rest before any listing (first chat on an account, WSL, a failed read) still + // gets its picker: an unknown listing keeps the static seed, as the host's catalog contract says. + it('keeps the static seed models when the host lists none and names no model', () => { + const state = applyStructuredAgentSessionOptions( + createStructuredAgentSessionOptionState('codex'), + CODEX_SESSION_OPTION_CATALOG, + { models: [], current: { model: '' } } + ) + const model = structuredAgentSessionOptionSnapshot(state).find((row) => row.id === 'model') + + expect( + model?.kind.type === 'select' ? model.kind.choices.map((choice) => choice.value) : [] + ).toEqual(CODEX_SESSION_OPTION_CATALOG.models.map((entry) => entry.id)) + expect(state.catalog?.defaultModelIsCliDefault).toBe( + CODEX_SESSION_OPTION_CATALOG.defaultModelIsCliDefault + ) + }) + it('projects live options as directly settable descriptors', () => { const state = applyStructuredAgentSessionOptions( createStructuredAgentSessionOptionState('codex'), diff --git a/src/shared/structured-agent-session-options.ts b/src/shared/structured-agent-session-options.ts index 9f9ed358509b..20c8b5341826 100644 --- a/src/shared/structured-agent-session-options.ts +++ b/src/shared/structured-agent-session-options.ts @@ -76,13 +76,15 @@ export function structuredAgentSessionOptionCatalog( seed: AgentSessionOptionCatalog, result: AgentSessionOptionsResult ): AgentSessionOptionCatalog { - const models: CatalogModel[] = result.models.map((model) => + const listed: CatalogModel[] = result.models.map((model) => discoveredModel(model, result.fastModeSupport?.supported === true) ) + // No listing is an unknown one, not an empty one: the client keeps its static seed's models. + const models = listed.length > 0 ? listed : [...seed.models] // An unnamed model is unknown, not the listing's default: the picker names none until it is. const { defaultModelIsCliDefault: _seedNamesDefault, ...unnamed } = seed if (!result.current.model) { - return { ...unnamed, models } + return listed.length > 0 ? { ...unnamed, models } : { ...seed, models } } if (!models.some((model) => model.id === result.current.model)) { models.push({ From 0d2940b5cb0d2feb43e82b83cc99065a44c2b979 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:25:43 -0700 Subject: [PATCH 29/84] fix(native-chat): a launch prompt keeps waiting through an unknown answer; tests open on the host's snapshot An unknown dispatch is not final on this host: a Codex turn/start that timed out after writing the frame keeps its echo armed, and a late echo settles the message as accepted. The launch prompt's read now ends only on accepted, rejected, or the stream ending, so the caller no longer says the agent could not start (and hands the comments back) for a prompt the agent then runs. The test driver now opens with a snapshot, as the host's subscribe does, with cases where that snapshot already carries accepted or rejected, and the closed-chat case uses the rejection the host writes. --- ...ed-agent-session-launch-prompt-handover.ts | 11 +++--- ...gent-session-launch-prompt-test-support.ts | 37 ++++++++++++++++--- ...ctured-agent-session-launch-prompt.test.ts | 34 +++++++++++++++++ .../structured-agent-session-launch-prompt.ts | 5 ++- 4 files changed, 76 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt-handover.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt-handover.ts index 66d0806442b1..e941f4b6b30b 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt-handover.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt-handover.ts @@ -16,12 +16,12 @@ export function structuredLaunchPromptVerdict( switch (submission.dispatchState) { case 'accepted': return 'taken' + // Unknown is a hand-over whose answer was lost, which a late echo can still prove taken. case 'pending': + case 'unknown': return 'waiting' - // Rejected is a start that failed for good, or a chat closed first; unknown is a hand-over - // nobody can vouch for. + // A start that failed for good, or a chat closed first. case 'rejected': - case 'unknown': return 'not-taken' } } @@ -29,8 +29,9 @@ export function structuredLaunchPromptVerdict( // One read per launch prompt, whoever asks. const inFlight = new Map>() -/** Whether the chat's agent took the launch prompt. Anything that ends the read first (the chat - * closing, the stream failing) answers no: the caller keeps what it would have written. */ +/** Whether the chat's agent took the launch prompt: only accepted or rejected answer it. Anything + * that ends the read first (the stream ending or failing) answers no: the caller keeps what it + * would have written. */ export function awaitStructuredLaunchPromptTaken( sessionId: string, clientMessageId: string diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt-test-support.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt-test-support.ts index e06a09643c78..71a7a3a109c6 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt-test-support.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt-test-support.ts @@ -41,6 +41,12 @@ export const FIRST_START_FAILS = { { handedOverAt: 20_000 }, { dispatchState: 'accepted', resolvedAt: 20_001 } ], + /** The first hand-over's answer is lost, and a late echo proves the agent took it. */ + unknownThenTaken: [ + { handedOverAt: 20_000 }, + { dispatchState: 'unknown' }, + { dispatchState: 'accepted' } + ], /** Every start fails; the last try rejects the message. */ rejectedAfterTries: [ RETRYING(1), @@ -63,18 +69,24 @@ export type FirstMessageStream = { open: () => boolean } -/** Answers the send as accepted-but-queued and hands back the stream the host then publishes. */ +/** + * Answers the send as accepted-but-queued and hands back the stream the host then publishes: a + * snapshot first, as the host's subscribe always opens, then batches. `opened` is what the message + * already is when the reader subscribes, e.g. taken in the gap between the send's answer and then. + */ export function firstMessageStream( client: LaunchPromptClientMock, - clientMessageId: string + clientMessageId: string, + opened: Partial = {} ): Promise { - let current: AgentJournalSubmission = { clientMessageId, ...QUEUED } + const answered: AgentJournalSubmission = { clientMessageId, ...QUEUED } + let current: AgentJournalSubmission = { ...answered, ...opened } client.call.mockResolvedValue({ ok: true, replayed: false, fence: 1, cursor: { epoch: 'epoch-1', sequence: 1 }, - value: { clientMessageId, submission: current } + value: { clientMessageId, submission: answered } }) return new Promise((resolve) => { client.subscribe.mockImplementation( @@ -96,7 +108,22 @@ export function firstMessageStream( } }) queueMicrotask(() => { - publish(current) + onEvent({ + type: 'snapshot', + sessionId: 'session-1', + fence: 2, + page: { + sessionId: 'session-1', + epoch: 'epoch-1', + direction: 'tail', + items: [], + removedItemIds: [], + submissions: [current], + window: { oldest: null, newest: null, nextCursor: { epoch: 'epoch-1', sequence: 2 } }, + hasOlder: false, + hasNewer: false + } + }) resolve({ next: (change) => { current = { ...current, ...change } diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts index abd65663f6b3..223ff7c82f03 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts @@ -73,6 +73,40 @@ describe('settleStructuredAgentLaunchPrompt', () => { expect(onPromptDelivered).not.toHaveBeenCalled() }) + it('keeps waiting through an unknown answer that a late echo then proves taken', async () => { + const { stream, result, onPromptDelivered } = launch() + const host = await stream + const [handedOver, unknown, accepted] = FIRST_START_FAILS.unknownThenTaken + host.next(handedOver!) + host.next(unknown!) + await Promise.resolve() + expect(onPromptDelivered).not.toHaveBeenCalled() + + host.next(accepted!) + await expect(result).resolves.toEqual({ delivered: true, failureNotified: false }) + expect(onPromptDelivered).toHaveBeenCalledOnce() + }) + + // The host's subscribe opens with a snapshot, which already carries a verdict reached in the gap + // between the send's answer and the read. + it.each([ + ['taken', { dispatchState: 'accepted' as const }, true], + ['rejected', { dispatchState: 'rejected' as const, rejection: { kind: 'notSignedIn' } }, false] + ])('answers a first message already %s when the read opens', async (_case, opened, delivered) => { + const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'review this') + const onPromptDelivered = vi.fn() + void firstMessageStream(mocks, stagedEntry!.clientMessageId, opened) + + await expect( + settleStructuredAgentLaunchPrompt({ + launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), + options: { prompt: 'review this', onPromptDelivered }, + stagedEntry + }) + ).resolves.toEqual({ delivered, failureNotified: false }) + expect(onPromptDelivered).toHaveBeenCalledTimes(delivered ? 1 : 0) + }) + it('reports undelivered when the session stream fails before a verdict', async () => { const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'review this') void firstMessageStream(mocks, stagedEntry!.clientMessageId) diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index 531ffd44e99f..57ff38f89ff0 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -193,7 +193,10 @@ export function settleStructuredAgentLaunchPrompt(args: { // Admitted is not delivered: the chat's first message starts its agent, which may fail, so // a pending answer waits for the message's own final state. A queued draft keeps its old // meaning; a dispatch the chat's own outbox ran answered elsewhere. - const admitted = await dispatch.promise + // An unknown answer is a hand-over nobody can vouch for yet, still owed its final state. + const admitted = + (await dispatch.promise) || + (answer !== undefined && answer !== 'queued' && answer.dispatchState === 'unknown') const verdict = answer === undefined || answer === 'queued' ? null : structuredLaunchPromptVerdict(answer) const delivered = From e2a3301897f4c90dcdd4965e3736ac79865d3169 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:26:24 -0700 Subject: [PATCH 30/84] test(native-chat): give each already-settled launch prompt case its own session --- .../lib/structured-agent-session-launch-prompt.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts index 223ff7c82f03..6562951fb758 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts @@ -92,14 +92,16 @@ describe('settleStructuredAgentLaunchPrompt', () => { it.each([ ['taken', { dispatchState: 'accepted' as const }, true], ['rejected', { dispatchState: 'rejected' as const, rejection: { kind: 'notSignedIn' } }, false] - ])('answers a first message already %s when the read opens', async (_case, opened, delivered) => { - const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'review this') + ])('answers a first message already %s when the read opens', async (state, opened, delivered) => { + // Its own session, so no other case's read can answer for it. + const sessionId = `session-opened-${state}` + const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt(sessionId, 'review this') const onPromptDelivered = vi.fn() void firstMessageStream(mocks, stagedEntry!.clientMessageId, opened) await expect( settleStructuredAgentLaunchPrompt({ - launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), + launchResult: Promise.resolve({ sessionId, fence: 1 }), options: { prompt: 'review this', onPromptDelivered }, stagedEntry }) From e4806e934555fd350a8285484c02a952d44333a5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:29:23 -0700 Subject: [PATCH 31/84] fix(native-chat): a create adjudicates an unreconciled lease before it founds the chat again Every lease loads unreconciled, and the create's transaction now refuses one still unreconciled. Nothing on the create path ran the reconcile, so when the startup reconcile failed or had not reached the record, the relaunch of a chat an older host's start left behind was refused on every try until another chat's send ran it. The create now adjudicates that lease first, as a start does, and answers its refusal if it has one; the transaction's check stays as the guard. --- ...structured-agent-session-create-at-rest.ts | 8 ++ ...nt-session-create-reconciles-first.test.ts | 100 ++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-create-reconciles-first.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create-at-rest.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create-at-rest.ts index 5eec23700ae8..82edb04821ec 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create-at-rest.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create-at-rest.ts @@ -58,6 +58,14 @@ async function createAtRest( ) } } + // A record this host has not adjudicated since load is adjudicated here, as a start does: the + // create's transaction refuses one still unreconciled, and nothing else on this path would clear it. + if (store.getRecord(sessionId)?.lease.unreconciled) { + const unreconciled = await context.reconcileLeases(sessionId) + if (unreconciled) { + return { ok: false, refusal: unreconciled } + } + } // Read and checked before the record claims the provider conversation it adopts. const transcript = store.getRecord(sessionId) ? { ok: true as const, items: null } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create-reconciles-first.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create-reconciles-first.test.ts new file mode 100644 index 000000000000..8af59a1cc135 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create-reconciles-first.test.ts @@ -0,0 +1,100 @@ +// Every lease loads unreconciled. A create over the record an older host's failed start left must +// adjudicate that lease itself, as a start does: the create refuses one still unreconciled, and +// nothing else on the create path would clear it, so the chat's relaunch would be refused forever. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestAttachParams, + hostTestOperationId +} from './structured-agent-session-host-test-data' + +let root: string +let host: StructuredAgentSessionHost | null = null + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-create-reconciles-first-')) +}) + +afterEach(async () => { + await host?.flushAllStreamedEvents() + host = null + await rm(root, { recursive: true, force: true }) +}) + +/** What an older host left: its create reserved the record, the start failed and was proven gone. */ +async function leaveFailedCreate(): Promise { + const store = await openTestAgentSessionRecordStore(root) + const params = hostTestAttachParams(null) + const identity = { + sessionId: SESSION, + location: params.location, + provider: params.provider, + accountHome: params.accountHome + } + const operation = { callerKey: 'client-1', operationId: hostTestOperationId() } + await store.createAtRest({ + ...identity, + claimKeyId: 'key-1', + operation: { ...operation, fingerprint: 'old-create' }, + now: NOW + }) + const reserved = await store.reserveOwner({ + ...identity, + expectedFence: 1, + spawnToken: 'spawn-old', + claimKeyId: 'key-1', + handoffOperationId: operation.operationId, + probe: { outcome: 'reservation-unused' }, + operation: { ...operation, fingerprint: 'old-create' }, + now: NOW + }) + await store.settleFailedAcquisition({ + sessionId: SESSION, + fence: reserved.record.lease.runtimeFence, + spawnToken: 'spawn-old', + ...operation, + outcome: { status: 'failed', code: 'agent_session_operation_invalid', message: 'signed out' }, + exitProof: 'exit-proven', + now: NOW + }) + return store.getRecord(SESSION)!.lease.runtimeFence +} + +it('adjudicates an unreconciled lease, then founds the chat again at the next fence', async () => { + const leftFence = await leaveFailedCreate() + // A fresh process: every lease it loads is unreconciled until adjudicated. + const store = await openTestAgentSessionRecordStore(root) + expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(true) + host = new StructuredAgentSessionHost({ + store, + adapter: { + supportsCreate: () => true, + acquire: vi.fn(), + dispatch: vi.fn(), + cancelTurn: vi.fn(), + answerPrompt: vi.fn(), + setOption: vi.fn() + }, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + now: () => NOW, + probeOwner: async () => ({ outcome: 'pid-absent' }) + }) + + const created = await host.create({ callerKey: 'client-1' }, hostTestAttachParams(null)) + + expect(created).toMatchObject({ ok: true, replayed: false, fence: leftFence + 1 }) + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + unreconciled: false, + claimStatus: 'released', + deathEvidence: null + }) +}) From f6312da82e4b16f3f970b10155bb1ac38e155721 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:33:03 -0700 Subject: [PATCH 32/84] fix(agent-launch): a phone's launch prompt counts as delivered only once the agent takes it agent.launch reported a structured launch prompt as journaled as soon as the host accepted the send. A chat's first message now starts its agent, so the phone said "Agent started in X" and marked its review notes sent for an agent whose start then failed. The host now follows the first message for up to 60 s (inside the phone's 90 s prompted-launch timeout), through a start the host retries: accepted (or handed over with its answer lost) is journaled; rejected is not-delivered with the rejection's words as the launch warning; still starting at the budget is not-delivered with a warning that the agent is still starting, the prompt will be sent automatically, and not to send it again. Notes stay unsent in both. Temporary: phones that watch the message's own settlement are the follow-up. No wire change. --- .../agent-launch-executor.test.ts | 24 +++- .../agent-launch/agent-launch-executor.ts | 8 +- .../agent-launch-prompt-delivery.ts | 26 ++-- .../agent-launch-surface-factories.ts | 17 ++- .../agent-launch-structured-prompt.test.ts | 120 +++++++++++++++++- .../methods/agent-launch-structured-prompt.ts | 50 ++++++++ .../rpc/methods/agent-launch-surfaces.ts | 4 +- 7 files changed, 225 insertions(+), 24 deletions(-) diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index e7270287fb53..9b9611090763 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -10,7 +10,10 @@ import { describe, expect, it, vi } from 'vitest' import { executeAgentLaunch, type AgentLaunchExecution } from './agent-launch-executor' -import { AgentLaunchStructuredSessionRefusedError } from './agent-launch-surface-factories' +import { + AgentLaunchStructuredSessionRefusedError, + type StructuredLaunchPromptDelivery +} from './agent-launch-surface-factories' import type { AgentLaunchIntent } from '../../shared/agent-launch-intent' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' @@ -26,6 +29,7 @@ function harness(options: { createSupportThrows?: boolean structuredCreateError?: Error deliveredMessageId?: string | null + notTakenWarning?: string terminalPromptDelivered?: boolean }) { const calls: string[] = [] @@ -60,9 +64,15 @@ function harness(options: { calls.push('createTerminalAgent') return { handle: 'term_1' } }) - const deliverStructuredPrompt = vi.fn(async () => { + const deliverStructuredPrompt = vi.fn(async (): Promise => { calls.push('deliverStructuredPrompt') - return options.deliveredMessageId === undefined ? 'msg-1' : options.deliveredMessageId + if (options.deliveredMessageId === null) { + return { + taken: false, + ...(options.notTakenWarning ? { warning: options.notTakenWarning } : {}) + } + } + return { taken: true, messageId: options.deliveredMessageId ?? 'msg-1' } }) const deliverTerminalPrompt = vi.fn(async () => { calls.push('deliverTerminalPrompt') @@ -280,6 +290,14 @@ describe('the prompt receipt', () => { expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'not-delivered' }) }) + it("reports the host's words for a prompt the agent did not take as the launch's warning", async () => { + const h = harness({ deliveredMessageId: null, notTakenWarning: 'The agent is still starting.' }) + const result = await h.run({ ...CREATE_INTENT, prompt: SUBMIT }) + + expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'not-delivered' }) + expect(result.warning).toBe('The agent is still starting.') + }) + it('leaves a draft with the caller, because the host has no composer to hold one', async () => { const h = harness({}) const result = await h.run({ diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 1d9bae4f45d3..2d593234251b 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -164,13 +164,17 @@ export async function executeAgentLaunch( // stale once the launch recovers by building a working one, so the user can be told the agent did // not start while looking at it. Telling those apart needs `createManagedWorktree` to stop // multiplexing "couldn't copy untracked files" and "startup terminal failed" into one string. - const warning = combineLaunchWarnings(placed.warning, created.warning) + const prompt = await settleLaunchPromptDisposal(execution, created) + const warning = combineLaunchWarnings( + combineLaunchWarnings(placed.warning, created.warning), + prompt.warning + ) return { outcome: created.outcome, worktreeId: placed.worktreeId, receipt: settled, ...(warning ? { warning } : {}), - ...promptReceipt(intent, await settleLaunchPromptDisposal(execution, created)) + ...promptReceipt(intent, prompt.disposal) } } diff --git a/src/main/agent-launch/agent-launch-prompt-delivery.ts b/src/main/agent-launch/agent-launch-prompt-delivery.ts index e77e0cabbe56..e1a4cb36f198 100644 --- a/src/main/agent-launch/agent-launch-prompt-delivery.ts +++ b/src/main/agent-launch/agent-launch-prompt-delivery.ts @@ -25,25 +25,31 @@ import type { import { agentPromptRidesLaunchCommand } from '../../shared/tui-agent-startup' import type { AgentLaunchModeReceipt } from './agent-launch-mode' import type { AgentLaunchExecution, CreatedSurface } from './agent-launch-executor' -import type { AgentLaunchStructuredSurface } from './agent-launch-surface-factories' +import type { + AgentLaunchStructuredSurface, + StructuredLaunchPromptDelivery +} from './agent-launch-surface-factories' export const HANDED_TO_TERMINAL: AgentLaunchPromptDisposal = { outcome: 'handed-to-terminal' } const NOT_DELIVERED: AgentLaunchPromptDisposal = { outcome: 'not-delivered' } -/** Each surface delivers its own way, so the disposal is decided where the surface is known. */ +/** Each surface delivers its own way, so the disposal is decided where the surface is known. A + * structured prompt the agent did not take can carry the host's words for why, as a launch warning. */ export async function settleLaunchPromptDisposal( execution: AgentLaunchExecution, created: CreatedSurface -): Promise { +): Promise<{ disposal: AgentLaunchPromptDisposal; warning?: string }> { if (created.structured) { - const messageId = await deliverStructuredLaunchPrompt(execution, created.structured) - return messageId ? { outcome: 'journaled', messageId } : NOT_DELIVERED + const delivered = await deliverStructuredLaunchPrompt(execution, created.structured) + return delivered.taken + ? { disposal: { outcome: 'journaled', messageId: delivered.messageId } } + : { disposal: NOT_DELIVERED, ...(delivered.warning ? { warning: delivered.warning } : {}) } } // The startup command already carries an argv agent's prompt; there is nothing left to write. if (created.promptRodeLaunchCommand) { - return HANDED_TO_TERMINAL + return { disposal: HANDED_TO_TERMINAL } } - return deliverTerminalLaunchPrompt(execution, created.outcome.handle) + return { disposal: await deliverTerminalLaunchPrompt(execution, created.outcome.handle) } } /** @@ -54,17 +60,17 @@ export async function settleLaunchPromptDisposal( async function deliverStructuredLaunchPrompt( execution: AgentLaunchExecution, structured: AgentLaunchStructuredSurface -): Promise { +): Promise { const { intent, surfaces } = execution if (!intent.prompt || intent.prompt.delivery !== 'submit') { - return null + return { taken: false } } return ( (await surfaces.deliverStructuredPrompt?.({ sessionId: structured.sessionId, fence: structured.fence, prompt: intent.prompt - })) ?? null + })) ?? { taken: false } ) } diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index 867eac40ab61..ce00ab40fe69 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -10,6 +10,11 @@ import type { TuiAgent } from '../../shared/tui-agent' /** How a surface is built once the executor has decided which one. Injected because an * orchestration worker's session carries a redrive subscription and a mailbox a plain launch * must not take, while the decision and ordering above it are identical. */ +/** What became of a structured launch prompt; see `deliverStructuredPrompt`. */ +export type StructuredLaunchPromptDelivery = + | { taken: true; messageId: string } + | { taken: false; warning?: string } + export type AgentLaunchSurfaceFactory = { createStructuredSession(args: { worktreeId: string @@ -42,17 +47,19 @@ export type AgentLaunchSurfaceFactory = { warning?: string }> /** - * Commits the launch text as the session's first turn, answering with the transcript row's id. + * Sends the launch text as the session's first message, which starts its agent, and answers + * whether the agent took it (with the transcript row's id) or not (with the host's words for why + * in `warning`, when it has them). * - * `null` means nothing was committed, and is the answer for every failure — a refused send, an - * unreachable host, a throw. Delivery must not fail a launch whose agent is already running: the - * caller can resend under `not-delivered`, but it cannot un-create a workspace. + * Not taken is the answer for every failure — a refused send, an unreachable host, a throw, a start + * that failed or is still being retried. Delivery must not fail a launch whose chat already + * exists: the caller keeps the text under `not-delivered`, but it cannot un-create a workspace. */ deliverStructuredPrompt?(args: { sessionId: string fence: number prompt: AgentLaunchPrompt - }): Promise + }): Promise /** * Writes the launch text into a terminal agent's live PTY, answering whether it landed. * 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 index 4d919df556ed..5e4bf216922b 100644 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts @@ -6,8 +6,15 @@ * that no failure mode can return an id, because an id is what the caller reads as "committed". */ -import { describe, expect, it, vi } from 'vitest' -import { commitStructuredAgentSessionLaunchPrompt } from './agent-launch-structured-prompt' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' +import { StructuredAgentSessionSendSettlement } from '../../../native-chat/agent-session-wire/structured-agent-session-send-settlement' +import { + commitStructuredAgentSessionLaunchPrompt, + deliverStructuredAgentSessionLaunchPrompt, + STRUCTURED_LAUNCH_PROMPT_SETTLEMENT_BUDGET_MS, + STRUCTURED_LAUNCH_PROMPT_STILL_STARTING +} from './agent-launch-structured-prompt' import { structuredAgentSessionPayloadFingerprint } from '../../../../shared/structured-agent-session-mutation' import { structuredAgentSessionSendBody } from '../../../../shared/structured-agent-session-outbox' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' @@ -94,3 +101,112 @@ describe('committing a launch prompt', () => { expect(send).not.toHaveBeenCalled() }) }) + +/** A host whose first message the test moves along, read through the host's own settlement wait. */ +function settlingHost() { + let submission: AgentJournalSubmission | undefined + const journal = { + submissions: () => (submission ? [submission] : []), + cursor: () => ({ epoch: 'epoch-1', sequence: 2 }), + activeTurnId: () => null + } + const settlement = new StructuredAgentSessionSendSettlement(() => journal) + const send = vi.fn( + async (_caller: unknown, params: { envelope: { clientOperationId: string } }) => { + submission = { + clientMessageId: params.envelope.clientOperationId, + fence: 4, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null, + handoverRecorded: true + } + return { + ok: true as const, + value: { clientMessageId: submission.clientMessageId, submission } + } + } + ) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements the only host methods the delivery reaches. + const host = { + send, + journalSnapshot: vi.fn(async () => ({ submissions: journal.submissions() })), + waitForSendSettlement: settlement.wait + } as unknown as StructuredAgentSessionHost + return { + host, + /** The host publishing the message's next state, as its journal commits do. */ + becomes: (change: Partial) => { + submission = { ...submission!, ...change } + settlement.publish('sess-1', journal) + } + } +} + +function deliver(host: StructuredAgentSessionHost) { + return deliverStructuredAgentSessionLaunchPrompt({ + host, + caller: CALLER, + sessionId: 'sess-1', + fence: 4, + text: 'review my notes' + }) +} + +describe('a launch prompt followed to whether its agent took it', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('names the row once the agent takes it', async () => { + const { host, becomes } = settlingHost() + const delivered = deliver(host) + await vi.waitFor(() => expect(host.send).toHaveBeenCalled()) + becomes({ dispatchState: 'accepted', resolvedAt: 2 }) + + await expect(delivered).resolves.toMatchObject({ taken: true, messageId: expect.any(String) }) + }) + + it("is not taken when the start fails for good, in the host's words", async () => { + const { host, becomes } = settlingHost() + const delivered = deliver(host) + await vi.waitFor(() => expect(host.send).toHaveBeenCalled()) + becomes({ + dispatchState: 'rejected', + reason: 'Claude is not signed in. Sign in, then send your message again.', + rejection: { kind: 'notSignedIn' } + }) + + await expect(delivered).resolves.toEqual({ + taken: false, + warning: 'Claude is not signed in. Sign in, then send your message again.' + }) + }) + + it('waits through a retried start, and at the budget says it is still starting, never later', async () => { + vi.useFakeTimers() + const { host, becomes } = settlingHost() + let answer: unknown + void deliver(host).then((value) => { + answer = value + }) + await vi.advanceTimersByTimeAsync(0) + becomes({ + startFailure: { + attempts: 1, + reason: 'An account switch is in progress.', + rejection: { kind: 'accountSwitchInProgress' }, + failedAt: 1, + nextAttemptAt: 15_001 + } + }) + + await vi.advanceTimersByTimeAsync(STRUCTURED_LAUNCH_PROMPT_SETTLEMENT_BUDGET_MS - 1) + expect(answer).toBeUndefined() + await vi.advanceTimersByTimeAsync(1) + expect(answer).toEqual({ taken: false, warning: STRUCTURED_LAUNCH_PROMPT_STILL_STARTING }) + }) +}) diff --git a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts index 11762137ba2f..9b057b615a12 100644 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts +++ b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts @@ -24,6 +24,8 @@ import { createStructuredAgentSessionOperationId } from '../../../../shared/stru 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' +import type { StructuredLaunchPromptDelivery } from '../../../agent-launch/agent-launch-surface-factories' +import { agentSessionSendSubmission } from '../../../../shared/agent-session-wire' /** * The committed transcript row's id, or `null` when nothing was committed. @@ -76,3 +78,51 @@ export async function commitStructuredAgentSessionLaunchPrompt(args: { return null } } + +/** + * How long a launch waits for the agent to take its first message. A chat's first message starts + * its agent, so a caller told "delivered" would claim a start that may still fail; the phone's + * prompted launch gives up after 90 s, and the create before this is quick. + */ +export const STRUCTURED_LAUNCH_PROMPT_SETTLEMENT_BUDGET_MS = 60_000 + +/** Said of a prompt whose agent is still starting when the wait ends: it will still be sent. */ +export const STRUCTURED_LAUNCH_PROMPT_STILL_STARTING = + "The agent is still starting. Its prompt will be sent automatically once it starts, so don't send it again." + +/** + * The launch prompt sent as the chat's first message, followed to whether the agent took it. + * + * Temporary: an older phone reads any committed row as "Agent started" and marks its notes sent, + * so the host answers only once the start has settled, within the budget. A start still being + * retried at the budget answers not taken, keeping the notes, with words saying it will still be + * sent. The follow-up is phones that watch the message's own settlement and finish then. + */ +export async function deliverStructuredAgentSessionLaunchPrompt( + args: Parameters[0] & { budgetMs?: number } +): Promise { + const messageId = await commitStructuredAgentSessionLaunchPrompt(args) + if (!messageId || !args.host) { + return { taken: false } + } + const settled = await args.host + .waitForSendSettlement(args.sessionId, messageId, { + budgetMs: args.budgetMs ?? STRUCTURED_LAUNCH_PROMPT_SETTLEMENT_BUDGET_MS, + throughStartRetries: true + }) + .catch(() => undefined) + const submission = agentSessionSendSubmission(settled?.value) + switch (submission?.dispatchState) { + // Unknown is a hand-over whose answer was lost: the agent was given it. + case 'accepted': + case 'unknown': + return { taken: true, messageId } + case 'rejected': + return { + taken: false, + warning: submission.reason ?? "The agent couldn't start, so its prompt wasn't sent." + } + default: + return { taken: false, warning: STRUCTURED_LAUNCH_PROMPT_STILL_STARTING } + } +} diff --git a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts index 3f184cd50b5c..33dd25a462c1 100644 --- a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts +++ b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts @@ -26,7 +26,7 @@ import type { StructuredAgentSessionHost } from '../../../native-chat/agent-sess import type { RpcContext } from '../core' import { structuredCallerFor } from './structured-agent-session-gate' import { createStructuredAgentSessionForWorktree } from './structured-agent-session-create' -import { commitStructuredAgentSessionLaunchPrompt } from './agent-launch-structured-prompt' +import { deliverStructuredAgentSessionLaunchPrompt } from './agent-launch-structured-prompt' import { deliverTerminalAgentLaunchPrompt } from './agent-launch-terminal-prompt' import { AgentLaunchSessionAlreadyExistsError } from '../../../../shared/agent-launch-session-already-exists' import { createStructuredAgentSessionId } from '../../../../shared/structured-agent-session-create' @@ -101,7 +101,7 @@ export function agentLaunchSurfaceFactory( } }, deliverStructuredPrompt: async ({ sessionId, fence, prompt }) => - commitStructuredAgentSessionLaunchPrompt({ + deliverStructuredAgentSessionLaunchPrompt({ host: getStructuredAgentSessionHost(), caller: operationCallerKey ? { callerKey: operationCallerKey } From 463fac6a8a0824b5fa5bcdcdf58b683085c327d3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:34:28 -0700 Subject: [PATCH 33/84] refactor(agent-launch): name every state a launch prompt's settlement can end in --- .../runtime/rpc/methods/agent-launch-structured-prompt.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts index 9b057b615a12..159b3168536e 100644 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts +++ b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts @@ -122,7 +122,9 @@ export async function deliverStructuredAgentSessionLaunchPrompt( taken: false, warning: submission.reason ?? "The agent couldn't start, so its prompt wasn't sent." } - default: + // Still waiting at the budget (a start being retried, or one still running). + case 'pending': + case undefined: return { taken: false, warning: STRUCTURED_LAUNCH_PROMPT_STILL_STARTING } } } From c0a419c34134045c2dc1ec0bab26f5af207e79ee Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:36:23 -0700 Subject: [PATCH 34/84] fix(agent-launch): a launch prompt still waiting on a retried start at the budget is an unknown outcome At the 60 s budget, a structured launch prompt whose start the host is still retrying no longer answers not-delivered (which an older phone shows as "The agent started, but the prompt wasn't sent", inviting a second copy of text the host will still send). agent.launch now refuses as agent_session_operation_unknown, which every phone shows as "Couldn't confirm the agent started. Check the workspace before trying again." and keeps its notes unsent. Temporary, as the rest of the phone's launch-prompt wait. --- .../agent-launch-executor.test.ts | 10 ++++++ .../agent-launch-surface-factories.ts | 5 +-- .../agent-launch-structured-prompt.test.ts | 35 +++++++++++++++---- .../methods/agent-launch-structured-prompt.ts | 16 ++++++--- 4 files changed, 52 insertions(+), 14 deletions(-) diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index 9b9611090763..f48d73bee75d 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -290,6 +290,16 @@ describe('the prompt receipt', () => { expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'not-delivered' }) }) + it('fails the launch as unknown when its prompt is still waiting on a retried start', async () => { + const h = harness({}) + const stillStarting = Object.assign(new Error('agent_session_operation_unknown'), { + code: 'agent_session_operation_unknown' + }) + h.deliverStructuredPrompt.mockRejectedValueOnce(stillStarting) + + await expect(h.run({ ...CREATE_INTENT, prompt: SUBMIT })).rejects.toBe(stillStarting) + }) + it("reports the host's words for a prompt the agent did not take as the launch's warning", async () => { const h = harness({ deliveredMessageId: null, notTakenWarning: 'The agent is still starting.' }) const result = await h.run({ ...CREATE_INTENT, prompt: SUBMIT }) diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index ce00ab40fe69..fe2198ea8a9f 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -52,8 +52,9 @@ export type AgentLaunchSurfaceFactory = { * in `warning`, when it has them). * * Not taken is the answer for every failure — a refused send, an unreachable host, a throw, a start - * that failed or is still being retried. Delivery must not fail a launch whose chat already - * exists: the caller keeps the text under `not-delivered`, but it cannot un-create a workspace. + * that failed — since the caller keeps the text under `not-delivered` but cannot un-create a + * workspace. A start still being retried when the wait ends is the exception: it throws an unknown + * outcome, because the text will still be sent and "not delivered" would invite a second copy. */ deliverStructuredPrompt?(args: { sessionId: string 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 index 5e4bf216922b..32c8662e691a 100644 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts @@ -186,13 +186,19 @@ describe('a launch prompt followed to whether its agent took it', () => { }) }) - it('waits through a retried start, and at the budget says it is still starting, never later', async () => { + it('waits through a retried start, and at the budget refuses as an unknown outcome, never later', async () => { vi.useFakeTimers() const { host, becomes } = settlingHost() - let answer: unknown - void deliver(host).then((value) => { - answer = value - }) + let refused: unknown + let answered = false + void deliver(host).then( + () => { + answered = true + }, + (error: unknown) => { + refused = error + } + ) await vi.advanceTimersByTimeAsync(0) becomes({ startFailure: { @@ -205,8 +211,23 @@ describe('a launch prompt followed to whether its agent took it', () => { }) await vi.advanceTimersByTimeAsync(STRUCTURED_LAUNCH_PROMPT_SETTLEMENT_BUDGET_MS - 1) - expect(answer).toBeUndefined() + expect(refused).toBeUndefined() await vi.advanceTimersByTimeAsync(1) - expect(answer).toEqual({ taken: false, warning: STRUCTURED_LAUNCH_PROMPT_STILL_STARTING }) + expect(answered).toBe(false) + expect(refused).toMatchObject({ + refusal: { + code: 'agent_session_operation_unknown', + message: STRUCTURED_LAUNCH_PROMPT_STILL_STARTING + } + }) + }) + + it('waits through an unknown hand-over and names the row', async () => { + const { host, becomes } = settlingHost() + const delivered = deliver(host) + await vi.waitFor(() => expect(host.send).toHaveBeenCalled()) + becomes({ handedOverAt: 2, dispatchState: 'unknown' }) + + await expect(delivered).resolves.toMatchObject({ taken: true, messageId: expect.any(String) }) }) }) diff --git a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts index 159b3168536e..5ce7a49bc9c2 100644 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts +++ b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts @@ -26,6 +26,7 @@ import type { StructuredAgentSessionHost } from '../../../native-chat/agent-sess import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' import type { StructuredLaunchPromptDelivery } from '../../../agent-launch/agent-launch-surface-factories' import { agentSessionSendSubmission } from '../../../../shared/agent-session-wire' +import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' /** * The committed transcript row's id, or `null` when nothing was committed. @@ -86,17 +87,18 @@ export async function commitStructuredAgentSessionLaunchPrompt(args: { */ export const STRUCTURED_LAUNCH_PROMPT_SETTLEMENT_BUDGET_MS = 60_000 -/** Said of a prompt whose agent is still starting when the wait ends: it will still be sent. */ +/** Why a launch whose agent is still starting when the wait ends cannot say how it went. */ export const STRUCTURED_LAUNCH_PROMPT_STILL_STARTING = - "The agent is still starting. Its prompt will be sent automatically once it starts, so don't send it again." + 'The agent is still starting; its prompt will be sent automatically once it starts.' /** * The launch prompt sent as the chat's first message, followed to whether the agent took it. * * Temporary: an older phone reads any committed row as "Agent started" and marks its notes sent, * so the host answers only once the start has settled, within the budget. A start still being - * retried at the budget answers not taken, keeping the notes, with words saying it will still be - * sent. The follow-up is phones that watch the message's own settlement and finish then. + * retried at the budget refuses the launch as an unknown outcome: the phone keeps its notes and + * says it couldn't confirm the start, and the message is still sent when the agent starts. The + * follow-up is phones that watch the message's own settlement and finish then. */ export async function deliverStructuredAgentSessionLaunchPrompt( args: Parameters[0] & { budgetMs?: number } @@ -125,6 +127,10 @@ export async function deliverStructuredAgentSessionLaunchPrompt( // Still waiting at the budget (a start being retried, or one still running). case 'pending': case undefined: - return { taken: false, warning: STRUCTURED_LAUNCH_PROMPT_STILL_STARTING } + throw agentSessionRefusalError( + 'agent_session_operation_unknown', + { reason: 'outcomeUnknown' }, + STRUCTURED_LAUNCH_PROMPT_STILL_STARTING + ) } } From 47fb51de21147db541c45dba5e61a85db2955f5f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:43:22 -0700 Subject: [PATCH 35/84] fix(native-chat): a launch prompt rejected for good is sent again from its source, not from the chat A launch prompt rejected after its tries (Claude not installed, signed out) kept the chat's own Retry. Retrying there delivered it after the source had already settled it as not delivered, so review notes never cleared and a GitHub acknowledgement never posted (or a second send from the source duplicated it). The source now owns that retry: the chat says "Not sent: . Send it again from where you started it." with no Retry, read from the launch outbox entry the renderer already persists (source: launch), so it survives a reload and holds in every window. Typed messages keep their Retry, and a start still being retried still waits. The notes and browser annotation send menu says when the agent did not take the prompt, so the next step is plain. --- .../components/editor/NotesSendMenu.test.tsx | 12 +++++ ...red-agent-session-delivery-notices.test.ts | 52 +++++++++++++++++++ ...ructured-agent-session-delivery-notices.ts | 25 ++++++++- ...omment-resolution-launch-delivery.test.tsx | 17 ++++++ .../tab-bar/QuickLaunchButton.test.ts | 21 +++++++- .../components/tab-bar/QuickLaunchButton.tsx | 22 ++++++++ src/renderer/src/i18n/locales/en.json | 3 ++ src/renderer/src/i18n/locales/es.json | 3 ++ src/renderer/src/i18n/locales/fr.json | 3 ++ src/renderer/src/i18n/locales/ja.json | 3 ++ src/renderer/src/i18n/locales/ko.json | 3 ++ src/renderer/src/i18n/locales/zh.json | 3 ++ 12 files changed, 164 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/components/editor/NotesSendMenu.test.tsx b/src/renderer/src/components/editor/NotesSendMenu.test.tsx index 6e4a69bdefe0..29abe4844456 100644 --- a/src/renderer/src/components/editor/NotesSendMenu.test.tsx +++ b/src/renderer/src/components/editor/NotesSendMenu.test.tsx @@ -508,4 +508,16 @@ describe('NotesSendMenu sending to a new chat', () => { await expect(delivery).resolves.toMatchObject({ delivered: false }) expect(onDelivered).not.toHaveBeenCalled() }) + + it('marks the notes sent once, when sent again from the notes after a start that failed for good', async () => { + const onDelivered = vi.fn() + const first = await sendToNewChat(onDelivered) + play(first.stream, FIRST_START_FAILS.rejectedAfterTries) + await expect(first.delivery).resolves.toMatchObject({ delivered: false }) + + const again = await sendToNewChat(onDelivered) + play(again.stream, FIRST_START_FAILS.retriedThenTaken) + await expect(again.delivery).resolves.toMatchObject({ delivered: true }) + expect(onDelivered).toHaveBeenCalledOnce() + }) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index 8d5920a36655..9cde6b5f334f 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -567,6 +567,58 @@ describe('the notice on each message that did not go through', () => { }) }) +// A launch prompt belongs to where it was sent from (notes, review comments, a fix action), which +// still holds it as unsent once the host rejects it for good: that is where it goes again. +describe('the notice on a launch prompt the host rejected for good', () => { + const notInstalled = { + kind: 'rejected' as const, + reason: "Claude isn't installed.", + rejection: { kind: 'providerMissing' as const } + } + + it('says why and to send it again from its source, with no Retry here', () => { + const retry = vi.fn() + const notices = structuredAgentSessionDeliveryNotices( + [ + entry('launch', { source: 'launch', state: 'rejected', lastFailure: notInstalled }), + entry('typed', { state: 'rejected', lastFailure: notInstalled }) + ], + 'Claude', + retry, + [], + [], + NOT_FAILED_HERE + ) + + const launch = notices.get(agentJournalSubmissionKey('launch')) + expect(launch?.onRetry).toBeUndefined() + expect(launch?.text).toBe( + "Not sent: Claude isn't installed. Install it, then retry. Send it again from where you started it." + ) + // A message typed in the chat keeps its own Retry. + expect(notices.get(agentJournalSubmissionKey('typed'))?.onRetry).toBeTypeOf('function') + }) + + it('keeps Retry on a launch prompt only held, not rejected', () => { + const notices = structuredAgentSessionDeliveryNotices( + [ + entry('launch', { + source: 'launch', + state: 'queued', + lastFailure: { kind: 'refused', code: 'agent_session_operation_capacity' } + }) + ], + 'Claude', + vi.fn(), + [], + [], + NOT_FAILED_HERE + ) + + expect(notices.get(agentJournalSubmissionKey('launch'))?.onRetry).toBeTypeOf('function') + }) +}) + describe('the notice on a message whose agent start failed', () => { function queued( id: string, diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts index e69601df5d16..98836dfc0abb 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts @@ -180,6 +180,23 @@ function deliveryNoticeText( ) } +/** A launch prompt the host rejected for good belongs to where it was sent from (notes, review + * comments, a fix action): that source still holds it as unsent and is where it goes again, so a + * Retry here would deliver it while the source never learns it went. */ +function sentFromSource(entry: StructuredAgentSessionOutboxEntry): boolean { + return entry.source === 'launch' && entry.state === 'rejected' +} + +function launchPromptNotSentText(reason: string): string { + return joinSentences([ + translate('components.native-chat.launchPromptNotSent', 'Not sent: {{reason}}', { reason }), + translate( + 'components.native-chat.launchPromptSendAgain', + 'Send it again from where you started it.' + ) + ]) +} + /** Keyed by the message id the transcript renders each entry under; `agentName` is the chat's * agent, for the words. */ export function structuredAgentSessionDeliveryNotices( @@ -213,14 +230,18 @@ export function structuredAgentSessionDeliveryNotices( const retryControl = stalledFrom === -1 || index <= stalledFrom const text = deliveryNoticeText( entry, - { agentName, retryControl }, + { agentName, retryControl: retryControl || sentFromSource(entry) }, rejected.get(entry.clientMessageId), startFailures, failedHere ) notices.set( agentJournalSubmissionKey(entry.clientMessageId), - retryControl ? { text, onRetry: () => retry(entry.clientMessageId) } : { text } + sentFromSource(entry) + ? { text: launchPromptNotSentText(text) } + : retryControl + ? { text, onRetry: () => retry(entry.clientMessageId) } + : { text } ) } } diff --git a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx index 2415820fdb85..62a09a26646a 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx @@ -214,4 +214,21 @@ describe('Resolve comments with AI, when the chat starts on its first message', }) } ) + + // The source owns the retry of a launch prompt the host rejected for good: the chat offers none, + // and sending again from the checks panel is what posts, once. + it('posts once when sent again from the checks panel after a start that failed for good', async () => { + const { model, hook } = acknowledgement() + const first = resolveCommentsWithAi(hook) + play(await first.host, FIRST_START_FAILS.rejectedAfterTries) + await expect(first.launched).resolves.toBe(false) + expectNothingPosted(model) + + const again = resolveCommentsWithAi(hook) + play(await again.host, FIRST_START_FAILS.retriedThenTaken) + await expect(again.launched).resolves.toBe(true) + + await vi.waitFor(() => expect(model.resolveReviewThread).toHaveBeenCalledTimes(1)) + expect(model.addPRConversationComment).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts b/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts index bb2f1cf02f73..203c90295ca5 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts @@ -1,7 +1,11 @@ import React from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { QuickLaunchAgentMenuItems, shouldShowLaunchWatchdogTimeout } from './QuickLaunchButton' +import { + QuickLaunchAgentMenuItems, + reportLaunchPromptNotTaken, + shouldShowLaunchWatchdogTimeout +} from './QuickLaunchButton' const { shortcutLabelMock, @@ -224,4 +228,19 @@ describe('shouldShowLaunchWatchdogTimeout', () => { }) ).toBe(true) }) + + // Notes and annotations sent to a new agent stay unsent when it never takes the prompt; the menu + // they were sent from says so, since that is where they go again. + it.each([ + [{ delivered: false, failureNotified: false }, 1], + [{ delivered: false, failureNotified: true }, 0], + [{ delivered: true, failureNotified: false }, 0] + ])('reports a launch prompt the agent did not take (%o)', async (delivery, toasts) => { + const { toast } = await import('sonner') + vi.mocked(toast.error).mockClear() + + await reportLaunchPromptNotTaken(Promise.resolve(delivery)) + + expect(toast.error).toHaveBeenCalledTimes(toasts) + }) }) diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index 026944575c83..1dede10528bc 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -18,6 +18,23 @@ import { import { translate } from '@/i18n/i18n' import { useStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch' +/** Says, once the launch prompt settles, that the agent did not take it, unless something already + * said so. */ +export async function reportLaunchPromptNotTaken( + delivery: Promise<{ delivered: boolean; failureNotified: boolean }> +): Promise { + const settled = await delivery.catch(() => ({ delivered: false, failureNotified: false })) + if (settled.delivered || settled.failureNotified) { + return + } + toast.error( + translate( + 'components.native-chat.launchPromptNotTaken', + "The agent didn't take the prompt, so nothing was marked sent. Send it again from here." + ) + ) +} + export type QuickLaunchAgentMenuItemsProps = { worktreeId: string groupId: string @@ -153,6 +170,11 @@ function QuickLaunchAgentMenuItemsInner({ ) return } + // A caller that marks something sent on delivery (notes, annotations) keeps it unsent when + // the agent never takes the prompt; say so, since this menu is where it goes again. + if (onPromptDelivered && result.promptDeliveryResult) { + void reportLaunchPromptNotTaken(result.promptDeliveryResult) + } if (result.surface.kind !== 'local-terminal') { return } diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index d259c235c13e..112ab39c0414 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17956,6 +17956,9 @@ "dismissDeliveryNotice": "Dismiss", "messageNotSent": "Message not sent", "startRetrying": "Orca will try again shortly.", + "launchPromptSendAgain": "Send it again from where you started it.", + "launchPromptNotSent": "Not sent: {{reason}}", + "launchPromptNotTaken": "The agent didn't take the prompt, so nothing was marked sent. Send it again from here.", "deliveryUnconfirmed": "Delivery unconfirmed — check chat before retrying", "queuedMessages": { "behindReturnedHold": "Waiting — a message ahead needs attention", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 245082b7833a..14f95f022d4d 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15178,6 +15178,9 @@ "dismissDeliveryNotice": "Descartar", "messageNotSent": "Mensaje no enviado", "startRetrying": "Orca lo intentará de nuevo en breve.", + "launchPromptSendAgain": "Vuelve a enviarlo desde donde lo iniciaste.", + "launchPromptNotSent": "No enviado: {{reason}}", + "launchPromptNotTaken": "El agente no recibió el mensaje, así que no se marcó nada como enviado. Vuelve a enviarlo desde aquí.", "deliveryUnconfirmed": "Entrega sin confirmar — revisa el chat antes de reintentar", "subagents": { "unnamed": "Subagente" diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 991341781f52..4b24afac864f 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17863,6 +17863,9 @@ "dismissDeliveryNotice": "Ignorer", "messageNotSent": "Message non envoyé", "startRetrying": "Orca réessaiera sous peu.", + "launchPromptSendAgain": "Renvoyez-le depuis l'endroit où vous l'avez lancé.", + "launchPromptNotSent": "Non envoyé : {{reason}}", + "launchPromptNotTaken": "L'agent n'a pas pris l'invite, donc rien n'a été marqué comme envoyé. Renvoyez-la d'ici.", "deliveryUnconfirmed": "Livraison non confirmée — vérifiez le chat avant de réessayer", "queuedMessages": { "alreadySent": "Ce message a déjà été envoyé.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 634209c277a8..9a6cdd509bd4 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17799,6 +17799,9 @@ "dismissDeliveryNotice": "閉じる", "messageNotSent": "メッセージは送信されませんでした", "startRetrying": "Orca がまもなく再試行します。", + "launchPromptSendAgain": "開始した場所からもう一度送信してください。", + "launchPromptNotSent": "未送信: {{reason}}", + "launchPromptNotTaken": "Agent がプロンプトを受け取らなかったため、送信済みにはなっていません。ここからもう一度送信してください。", "deliveryUnconfirmed": "配信を確認できません — 再試行する前にチャットを確認してください", "queuedMessages": { "alreadySent": "このメッセージはすでに送信されています。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 85ee9019c93d..ee8b81579c9d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17799,6 +17799,9 @@ "dismissDeliveryNotice": "닫기", "messageNotSent": "메시지가 전송되지 않음", "startRetrying": "Orca가 곧 다시 시도합니다.", + "launchPromptSendAgain": "시작한 곳에서 다시 보내세요.", + "launchPromptNotSent": "보내지 않음: {{reason}}", + "launchPromptNotTaken": "에이전트가 프롬프트를 받지 않아 보낸 것으로 표시되지 않았습니다. 여기에서 다시 보내세요.", "deliveryUnconfirmed": "전달 확인 안 됨 — 다시 시도하기 전에 채팅을 확인하세요", "queuedMessages": { "alreadySent": "이 메시지는 이미 전송되었습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 898af03f79f7..4605a3fddb3e 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17764,6 +17764,9 @@ "dismissDeliveryNotice": "关闭", "messageNotSent": "消息未发送", "startRetrying": "Orca 将很快重试。", + "launchPromptSendAgain": "请从你发起它的地方重新发送。", + "launchPromptNotSent": "未发送:{{reason}}", + "launchPromptNotTaken": "代理没有接收提示,因此没有标记为已发送。请从这里重新发送。", "deliveryUnconfirmed": "无法确认送达 — 重试前请检查聊天", "queuedMessages": { "alreadySent": "此消息已发送。", From 8c21d7ea9d479646d642fe839cd584c7ceae4370 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:49:38 -0700 Subject: [PATCH 36/84] fix(agent-launch): a launch prompt whose answer was lost keeps waiting for its final state A launch prompt answered unknown (handed over, answer lost) was reported journaled at once, so an older phone said the agent started and marked its notes sent. unknown can still turn accepted on a late echo, so the host now waits for the message's final state (a new 'final' settlement point) within the 60 s budget; still unknown at the budget refuses as agent_session_operation_unknown, as a start still being retried does. A host-backed test pins that the refused launch leaves the chat open and its first message starts the agent on the next try. --- ...tructured-agent-session-send-settlement.ts | 19 ++- ...ent-launch-structured-prompt-start.test.ts | 131 ++++++++++++++++++ .../agent-launch-structured-prompt.test.ts | 37 ++++- .../methods/agent-launch-structured-prompt.ts | 7 +- 4 files changed, 181 insertions(+), 13 deletions(-) create mode 100644 src/main/runtime/rpc/methods/agent-launch-structured-prompt-start.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts index 5310f1d55451..f0aaadc1b7ca 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts @@ -18,10 +18,15 @@ type SettledSend = { type SendSettlement = SettledSend | 'pending' | 'missing' -/** What ends a wait: the provider's answer, the host handing the message over, or either that or - * the message waiting behind a running command, which hands nothing over until it ends. A failed - * start recorded on the message ends each too, unless the wait runs `throughStartRetries`. */ -export type SendSettlementPoint = 'answered' | 'handed-over' | 'handed-over-or-behind-command' +/** What ends a wait: the provider's answer, its final answer (a lost one, `unknown`, can still + * turn accepted), the host handing the message over, or either that or the message waiting behind + * a running command, which hands nothing over until it ends. A failed start recorded on the + * message ends each too, unless the wait runs `throughStartRetries`. */ +export type SendSettlementPoint = + | 'answered' + | 'final' + | 'handed-over' + | 'handed-over-or-behind-command' export type SendSettlementWaitOptions = { signal?: AbortSignal @@ -67,8 +72,10 @@ function settledSend( (wait.throughStartRetries || !isRetryingStructuredAgentSessionStart(submission)) && (until === 'answered' ? submission.dispatchState === 'pending' - : isQueuedAgentJournalSubmission(submission) && - !(until === 'handed-over-or-behind-command' && runningCommand(journal))) + : until === 'final' + ? submission.dispatchState === 'pending' || submission.dispatchState === 'unknown' + : isQueuedAgentJournalSubmission(submission) && + !(until === 'handed-over-or-behind-command' && runningCommand(journal))) return waiting ? 'pending' : { cursor: journal.cursor(), value: { clientMessageId, submission } } } diff --git a/src/main/runtime/rpc/methods/agent-launch-structured-prompt-start.test.ts b/src/main/runtime/rpc/methods/agent-launch-structured-prompt-start.test.ts new file mode 100644 index 000000000000..85c4e4a5c44c --- /dev/null +++ b/src/main/runtime/rpc/methods/agent-launch-structured-prompt-start.test.ts @@ -0,0 +1,131 @@ +// A launch prompt is the new chat's first message, which starts its agent. Read from a real host: +// a start still being retried at the budget refuses the launch as unknown, and leaves the chat and +// its message to start on the next try. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' +import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' +import { + AgentSessionPreSpawnError, + type StructuredAgentSessionAdapter +} from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' +import { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams +} from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' +import { mintAgentSessionOperationId } from '../../orchestration/structured-pointer-operation-id' +import { deliverStructuredAgentSessionLaunchPrompt } from './agent-launch-structured-prompt' + +const CALLER = { callerKey: 'client-1' } + +let root: string +let host: StructuredAgentSessionHost +let acquire: Mock +let closeSession: Mock +let fence: number +let clock: number + +const accountSwitch = () => + new AgentSessionPreSpawnError(new Error('switching'), { reason: 'accountSwitchInProgress' }) + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-launch-prompt-start-')) + clock = Date.now() + const store = await openTestAgentSessionRecordStore(root) + acquire = vi.fn(async ({ fence: acquiredFence, spawnToken }) => ({ + process: { hostId: 'local', pid: 4242, processStartTimeMs: clock, spawnToken }, + link: { + linkId: `link-${acquiredFence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: acquiredFence, + observedAt: clock + } + })) + closeSession = vi.fn(async () => true) + host = new StructuredAgentSessionHost({ + store, + adapter: { + supportsCreate: () => true, + acquire, + releaseAcquisition: vi.fn(async () => true), + closeSession, + dispatch: vi.fn(async ({ clientMessageId }) => ({ + state: 'accepted' as const, + providerIdentity: { + provider: 'codex' as const, + threadId: THREAD, + turnId: `turn-${clientMessageId}`, + ordinal: 1 + } + })), + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(), + setOption: vi.fn() + }, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + now: () => clock + }) + const create = hostTestAttachParams(null) + create.envelope.clientOperationId = mintAgentSessionOperationId(clock) + const created = await host.create(CALLER, create) + if (!created.ok) { + throw new Error(created.refusal.message) + } + fence = created.fence +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +function deliver(budgetMs: number) { + return deliverStructuredAgentSessionLaunchPrompt({ + host, + caller: CALLER, + sessionId: SESSION, + fence, + text: 'review my notes', + budgetMs + }) +} + +describe('a launch prompt whose agent is still starting at the budget', () => { + it('refuses the launch as unknown, and the chat keeps its message and starts it later', async () => { + acquire.mockRejectedValueOnce(accountSwitch()) + + await expect(deliver(200)).rejects.toMatchObject({ + refusal: { code: 'agent_session_operation_unknown' } + }) + + // The chat survives the refused launch: nothing closed it, and its first message still waits. + expect(closeSession).not.toHaveBeenCalled() + expect(host.sessionAgent(SESSION)).not.toBeNull() + const [waiting] = (await host.journalSnapshot(SESSION)).submissions + expect(waiting).toMatchObject({ dispatchState: 'pending', startFailure: { attempts: 1 } }) + + // The retry comes due; the delivery loop starts the agent with that same message. + clock += 15_000 + host.collaboratorsForTests().conversationDelivery.loop.wake(SESSION) + await vi.waitFor(async () => + expect((await host.journalSnapshot(SESSION)).submissions).toEqual([ + expect.objectContaining({ + clientMessageId: waiting.clientMessageId, + dispatchState: 'accepted' + }) + ]) + ) + expect(acquire).toHaveBeenCalledTimes(2) + }) + + it('is taken when the start succeeds within the budget', async () => { + await expect(deliver(5_000)).resolves.toMatchObject({ taken: true }) + }) +}) 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 index 32c8662e691a..5c64b5b200b7 100644 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts @@ -222,12 +222,41 @@ describe('a launch prompt followed to whether its agent took it', () => { }) }) - it('waits through an unknown hand-over and names the row', async () => { + it('waits through a lost answer, and names the row once it turns accepted', async () => { + vi.useFakeTimers() const { host, becomes } = settlingHost() - const delivered = deliver(host) - await vi.waitFor(() => expect(host.send).toHaveBeenCalled()) + let answer: unknown + void deliver(host).then((value) => { + answer = value + }) + await vi.advanceTimersByTimeAsync(0) becomes({ handedOverAt: 2, dispatchState: 'unknown' }) + await vi.advanceTimersByTimeAsync(1_000) + expect(answer).toBeUndefined() - await expect(delivered).resolves.toMatchObject({ taken: true, messageId: expect.any(String) }) + becomes({ dispatchState: 'accepted', resolvedAt: 3 }) + await vi.advanceTimersByTimeAsync(0) + expect(answer).toMatchObject({ taken: true, messageId: expect.any(String) }) + }) + + it('refuses as an unknown outcome when the answer is still lost at the budget', async () => { + vi.useFakeTimers() + const { host, becomes } = settlingHost() + let refused: unknown + let answered = false + void deliver(host).then( + () => { + answered = true + }, + (error: unknown) => { + refused = error + } + ) + await vi.advanceTimersByTimeAsync(0) + becomes({ handedOverAt: 2, dispatchState: 'unknown' }) + + await vi.advanceTimersByTimeAsync(STRUCTURED_LAUNCH_PROMPT_SETTLEMENT_BUDGET_MS) + expect(answered).toBe(false) + expect(refused).toMatchObject({ refusal: { code: 'agent_session_operation_unknown' } }) }) }) diff --git a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts index 5ce7a49bc9c2..ba162065b39e 100644 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts +++ b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts @@ -110,22 +110,23 @@ export async function deliverStructuredAgentSessionLaunchPrompt( const settled = await args.host .waitForSendSettlement(args.sessionId, messageId, { budgetMs: args.budgetMs ?? STRUCTURED_LAUNCH_PROMPT_SETTLEMENT_BUDGET_MS, + until: 'final', throughStartRetries: true }) .catch(() => undefined) const submission = agentSessionSendSubmission(settled?.value) switch (submission?.dispatchState) { - // Unknown is a hand-over whose answer was lost: the agent was given it. case 'accepted': - case 'unknown': return { taken: true, messageId } case 'rejected': return { taken: false, warning: submission.reason ?? "The agent couldn't start, so its prompt wasn't sent." } - // Still waiting at the budget (a start being retried, or one still running). + // Still waiting at the budget: a start being retried or still running, or a hand-over whose + // answer was lost and may yet turn accepted. case 'pending': + case 'unknown': case undefined: throw agentSessionRefusalError( 'agent_session_operation_unknown', From 25fec280a9f4054241c31320016ebb5741c38a2d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 15:50:41 -0700 Subject: [PATCH 37/84] test(agent-launch): type the adapter close mock as the defined method --- .../rpc/methods/agent-launch-structured-prompt-start.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/runtime/rpc/methods/agent-launch-structured-prompt-start.test.ts b/src/main/runtime/rpc/methods/agent-launch-structured-prompt-start.test.ts index 85c4e4a5c44c..62b4d9659665 100644 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt-start.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-structured-prompt-start.test.ts @@ -26,7 +26,7 @@ const CALLER = { callerKey: 'client-1' } let root: string let host: StructuredAgentSessionHost let acquire: Mock -let closeSession: Mock +let closeSession: Mock> let fence: number let clock: number From 8e0169301dfd6730e83d97d657fc33c5432e6b32 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:05:06 -0700 Subject: [PATCH 38/84] fix(native-chat): notes sent to a chat count as sent only once its agent takes them Review notes and browser annotations sent to an existing chat at rest (now every chat before its first message) were removed at once with "Notes sent to active agent.", because sending only queued them on the chat's outbox. The chat's agent starts on that message and can fail for good (Claude not installed), losing the notes. sendMessageToAgent now marks the message as sent from another surface (outbox source 'surface') and waits on its own final state, through retried starts and lost answers, with the launch prompt's single waiter, which now also reads the chat's outbox (a send refused before the host recorded it) and a draft held behind a running turn. Only a taken message reports sent; a rejected or withdrawn one reports not-taken, so notes, annotations and the agent picker keep their items and show a failure. In the chat, a message from another surface that was not sent gets no Retry (rejected or held) and says "Send it again from where you started it."; a refused one keeps its id, since only its source sends it again. Its install step reads "Install it first." rather than "...then retry.". --- .../ReviewNotesSendMenuContent.test.tsx | 78 +++-- .../agent-session-failure-words-text.ts | 2 + ...red-agent-session-delivery-notices.test.ts | 19 +- ...ructured-agent-session-delivery-notices.ts | 19 +- ...structured-agent-session-outbox-storage.ts | 29 +- .../src/i18n/en-runtime-required.json | 1 + src/renderer/src/i18n/locales/en.json | 1 + src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + .../lib/active-agent-note-send-diagnostics.ts | 2 + .../src/lib/active-agent-note-send-result.ts | 6 + ...t-message-send-structured-delivery.test.ts | 279 ++++++++++++++++++ src/renderer/src/lib/agent-message-send.ts | 12 +- ...ed-agent-session-launch-prompt-handover.ts | 92 ------ ...gent-session-launch-prompt-test-support.ts | 14 +- ...ctured-agent-session-launch-prompt.test.ts | 6 +- .../structured-agent-session-launch-prompt.ts | 10 +- ...red-agent-session-sourced-message-taken.ts | 128 ++++++++ .../store/slices/ui-agent-send-target.test.ts | 12 +- src/shared/agent-session-failure-copy.ts | 1 + src/shared/agent-session-failure-words.ts | 15 +- src/shared/structured-agent-session-outbox.ts | 13 +- 25 files changed, 579 insertions(+), 165 deletions(-) create mode 100644 src/renderer/src/lib/agent-message-send-structured-delivery.test.ts delete mode 100644 src/renderer/src/lib/structured-agent-session-launch-prompt-handover.ts create mode 100644 src/renderer/src/lib/structured-agent-session-sourced-message-taken.ts diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx index 7c1961f7d7cf..30249f1f26ed 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx @@ -682,37 +682,55 @@ describe('ReviewNotesSendMenuContent', () => { }) }) - it('keeps selected-target note failures undelivered and uses selected wording', async () => { - const statusPaneKey = makePaneKey(TAB_A, LEAF_A) - const onPromptDelivered = vi.fn() - harness.sendMessageToAgent.mockResolvedValue({ status: 'not-ready' }) - setStore({ - tabsByWorktree: { 'wt-1': [tab(TAB_A, { title: 'Terminal 1' })] }, - terminalLayoutsByTabId: { [TAB_A]: leafLayout(LEAF_A, 'pty-a') } - }) - harness.noteTargets = [ - { - paneKey: statusPaneKey, - tabId: TAB_A, - messageTarget: { kind: 'terminal', tabId: TAB_A, leafId: LEAF_A }, - agentType: 'claude', - tabTitle: 'Terminal 1', - status: 'eligible' - } + it.each([ + ['a terminal not ready', { kind: 'terminal', tabId: TAB_A, leafId: LEAF_A }, 'not-ready'], + // A chat's agent starts on the notes: they wait for it, and stay unsent when it never takes them. + [ + 'a chat whose agent did not take them', + { kind: 'structured-session', sessionId: 'c1' }, + 'not-taken' ] - - const tree = render({ onPromptDelivered }) - ;(findByType(tree, 'DropdownMenuItem').props.onSelect as () => void)() - await flushMicrotasks() - - expect(onPromptDelivered).not.toHaveBeenCalled() - expect(harness.track).not.toHaveBeenCalled() - // Why: a failure must replace the 'Sending notes...' loading toast, not keep its spinner. - const settled = toast - .getToasts() - .find((entry) => 'title' in entry && entry.title === 'selected:not-ready') - expect(settled && 'type' in settled ? settled.type : undefined).toBe('info') - }) + ] as const)( + 'keeps notes to %s undelivered, in selected wording', + async (_case, target, status) => { + const onPromptDelivered = vi.fn() + let answer!: (result: { status: typeof status }) => void + harness.sendMessageToAgent.mockReturnValue(new Promise((resolve) => (answer = resolve))) + const successes = () => + toast.getToasts().filter((entry) => 'type' in entry && entry.type === 'success').length + const before = successes() + setStore({ + tabsByWorktree: { 'wt-1': [tab(TAB_A, { title: 'Terminal 1' })] }, + terminalLayoutsByTabId: { [TAB_A]: leafLayout(LEAF_A, 'pty-a') } + }) + harness.noteTargets = [ + { + paneKey: makePaneKey(TAB_A, LEAF_A), + tabId: TAB_A, + messageTarget: target, + agentType: 'claude', + tabTitle: 'Terminal 1', + status: 'eligible' + } + ] + + const tree = render({ onPromptDelivered }) + ;(findByType(tree, 'DropdownMenuItem').props.onSelect as () => void)() + await flushMicrotasks() + expect(onPromptDelivered).not.toHaveBeenCalled() + answer({ status }) + await flushMicrotasks() + + expect(onPromptDelivered).not.toHaveBeenCalled() + expect(harness.track).not.toHaveBeenCalled() + expect(successes()).toBe(before) + // Why: a failure must replace the 'Sending notes...' loading toast, not keep its spinner. + const settled = toast + .getToasts() + .find((entry) => 'title' in entry && entry.title === `selected:${status}`) + expect(settled && 'type' in settled ? settled.type : undefined).toBe('info') + } + ) it('keeps selected-target thrown send errors undelivered', async () => { const statusPaneKey = makePaneKey(TAB_A, LEAF_A) diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 613455e05b69..9da6c85c5fc8 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -62,6 +62,8 @@ const PIECES: Record + translate('components.native-chat.failureWords.installFirst', COPY.installFirst), installThenRetry: () => translate('components.native-chat.failureWords.installThenRetry', COPY.installThenRetry), installThenRunCommand: (values) => diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index 9cde6b5f334f..a259b2b2a7ca 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -569,7 +569,7 @@ describe('the notice on each message that did not go through', () => { // A launch prompt belongs to where it was sent from (notes, review comments, a fix action), which // still holds it as unsent once the host rejects it for good: that is where it goes again. -describe('the notice on a launch prompt the host rejected for good', () => { +describe('the notice on a message sent from elsewhere that was not sent', () => { const notInstalled = { kind: 'rejected' as const, reason: "Claude isn't installed.", @@ -593,20 +593,18 @@ describe('the notice on a launch prompt the host rejected for good', () => { const launch = notices.get(agentJournalSubmissionKey('launch')) expect(launch?.onRetry).toBeUndefined() expect(launch?.text).toBe( - "Not sent: Claude isn't installed. Install it, then retry. Send it again from where you started it." + "Not sent: Claude isn't installed. Install it first. Send it again from where you started it." ) // A message typed in the chat keeps its own Retry. expect(notices.get(agentJournalSubmissionKey('typed'))?.onRetry).toBeTypeOf('function') }) - it('keeps Retry on a launch prompt only held, not rejected', () => { + it('offers no Retry on notes the host refused and the chat holds, since their source sends them again', () => { + const refused = { kind: 'refused' as const, code: 'agent_session_operation_capacity' } const notices = structuredAgentSessionDeliveryNotices( [ - entry('launch', { - source: 'launch', - state: 'queued', - lastFailure: { kind: 'refused', code: 'agent_session_operation_capacity' } - }) + entry('notes', { source: 'surface', state: 'queued', lastFailure: refused }), + entry('typed', { state: 'queued', lastFailure: refused }) ], 'Claude', vi.fn(), @@ -615,7 +613,10 @@ describe('the notice on a launch prompt the host rejected for good', () => { NOT_FAILED_HERE ) - expect(notices.get(agentJournalSubmissionKey('launch'))?.onRetry).toBeTypeOf('function') + const notes = notices.get(agentJournalSubmissionKey('notes')) + expect(notes?.onRetry).toBeUndefined() + expect(notes?.text).toMatch(/Send it again from where you started it\.$/) + expect(notices.get(agentJournalSubmissionKey('typed'))?.onRetry).toBeTypeOf('function') }) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts index 98836dfc0abb..94bced4d3f3d 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts @@ -180,14 +180,17 @@ function deliveryNoticeText( ) } -/** A launch prompt the host rejected for good belongs to where it was sent from (notes, review - * comments, a fix action): that source still holds it as unsent and is where it goes again, so a - * Retry here would deliver it while the source never learns it went. */ +/** A message sent from elsewhere (a launch's prompt, review notes) and not sent belongs to where it + * came from: that source still holds it as unsent and is where it goes again, so a Retry here + * would deliver it while the source never learns it went. */ function sentFromSource(entry: StructuredAgentSessionOutboxEntry): boolean { - return entry.source === 'launch' && entry.state === 'rejected' + return ( + entry.source !== undefined && + (entry.state === 'rejected' || structuredAgentSessionEntryHeldForRetry(entry)) + ) } -function launchPromptNotSentText(reason: string): string { +function sourcedMessageNotSentText(reason: string): string { return joinSentences([ translate('components.native-chat.launchPromptNotSent', 'Not sent: {{reason}}', { reason }), translate( @@ -230,7 +233,9 @@ export function structuredAgentSessionDeliveryNotices( const retryControl = stalledFrom === -1 || index <= stalledFrom const text = deliveryNoticeText( entry, - { agentName, retryControl: retryControl || sentFromSource(entry) }, + sentFromSource(entry) + ? { agentName, retryControl: true, sourceRetries: true } + : { agentName, retryControl }, rejected.get(entry.clientMessageId), startFailures, failedHere @@ -238,7 +243,7 @@ export function structuredAgentSessionDeliveryNotices( notices.set( agentJournalSubmissionKey(entry.clientMessageId), sentFromSource(entry) - ? { text: launchPromptNotSentText(text) } + ? { text: sourcedMessageNotSentText(text) } : retryControl ? { text, onRetry: () => retry(entry.clientMessageId) } : { text } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts index ce270d60ddba..592e897bac6c 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts @@ -2,7 +2,8 @@ import { createStructuredAgentSessionOutboxEntry, parseStructuredAgentSessionOutboxEntry, type StructuredAgentSessionAttachment, - type StructuredAgentSessionOutboxEntry + type StructuredAgentSessionOutboxEntry, + type StructuredAgentSessionOutboxSource } from '../../../../shared/structured-agent-session-outbox' import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation' import { createBrowserUuid } from '@/lib/browser-uuid' @@ -153,6 +154,27 @@ export function subscribeToStructuredAgentSessionOutbox( } } +const committedListeners = new Map< + string, + Set<(entries: readonly StructuredAgentSessionOutboxEntry[]) => void> +>() + +/** Every outbox `entries` becomes, whether or not a chat holds it open. */ +export function subscribeToStructuredAgentSessionOutboxCommits( + sessionId: string, + listener: (entries: readonly StructuredAgentSessionOutboxEntry[]) => void +): () => void { + const listeners = committedListeners.get(sessionId) ?? new Set() + listeners.add(listener) + committedListeners.set(sessionId, listeners) + return () => { + listeners.delete(listener) + if (listeners.size === 0 && committedListeners.get(sessionId) === listeners) { + committedListeners.delete(sessionId) + } + } +} + /** Makes `entries` the session's outbox and saves it. With `onlyIfSaved`, a failed save leaves * the outbox as it was; otherwise the open chat still shows the change. */ export function commitStructuredAgentSessionOutbox( @@ -171,6 +193,9 @@ export function commitStructuredAgentSessionOutbox( listener() } } + for (const listener of committedListeners.get(sessionId) ?? []) { + listener(entries) + } return saved } @@ -180,7 +205,7 @@ export function appendStructuredAgentSessionOutboxMessage( sessionId: string, text: string, attachments: readonly StructuredAgentSessionAttachment[] = [], - source?: 'launch' + source?: StructuredAgentSessionOutboxSource ): StructuredAgentSessionOutboxEntry | null { const entry = { ...createStructuredAgentSessionOutboxEntry({ diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 81640ec15e41..5548d51fed3f 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2754,6 +2754,7 @@ "hostFaultTryAgain": "Orca ran into a problem, so this didn't go through. Try again.", "hostRestarted": "Orca restarted before this message was sent.", "hostStopped": "{{agent}} never finished starting, so Orca stopped it.", + "installFirst": "Install it first.", "installThenRetry": "Install it, then retry.", "installThenRunCommand": "Install it, then run /{{command}} again.", "installThenSend": "Install it, then send your message again.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 112ab39c0414..5efa042ea6a8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17500,6 +17500,7 @@ "signInThenRunCommand": "Sign in, then run /{{command}} again.", "signInThenSend": "Sign in, then send your message again.", "providerMissing": "{{agent}} isn't installed.", + "installFirst": "Install it first.", "installThenRetry": "Install it, then retry.", "installThenRunCommand": "Install it, then run /{{command}} again.", "installThenSend": "Install it, then send your message again.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 14f95f022d4d..7546a38e9d26 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14941,6 +14941,7 @@ "signInThenRunCommand": "Inicie sesión y vuelva a ejecutar /{{command}}.", "signInThenSend": "Inicie sesión y vuelva a enviar su mensaje.", "providerMissing": "{{agent}} no está instalado.", + "installFirst": "Instálelo primero.", "installThenRetry": "Instálelo y vuelva a intentarlo.", "installThenRunCommand": "Instálelo y vuelva a ejecutar /{{command}}.", "installThenSend": "Instálelo y vuelva a enviar su mensaje.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 4b24afac864f..a77a3808de22 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17407,6 +17407,7 @@ "signInThenRunCommand": "Connectez-vous, puis relancez /{{command}}.", "signInThenSend": "Connectez-vous, puis renvoyez votre message.", "providerMissing": "{{agent}} n'est pas installé.", + "installFirst": "Installez-le d'abord.", "installThenRetry": "Installez-le, puis réessayez.", "installThenRunCommand": "Installez-le, puis relancez /{{command}}.", "installThenSend": "Installez-le, puis renvoyez votre message.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 9a6cdd509bd4..a9b8111d8c6e 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17343,6 +17343,7 @@ "signInThenRunCommand": "サインインしてから、/{{command}} をもう一度実行してください。", "signInThenSend": "サインインしてから、メッセージをもう一度送信してください。", "providerMissing": "{{agent}} はインストールされていません。", + "installFirst": "先にインストールしてください。", "installThenRetry": "インストールしてから、もう一度お試しください。", "installThenRunCommand": "インストールしてから、/{{command}} をもう一度実行してください。", "installThenSend": "インストールしてから、メッセージをもう一度送信してください。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ee8b81579c9d..7967b1c6bf0d 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17343,6 +17343,7 @@ "signInThenRunCommand": "로그인한 다음 /{{command}}을(를) 다시 실행하세요.", "signInThenSend": "로그인한 다음 메시지를 다시 보내세요.", "providerMissing": "{{agent}}이(가) 설치되어 있지 않습니다.", + "installFirst": "먼저 설치하세요.", "installThenRetry": "설치한 다음 다시 시도하세요.", "installThenRunCommand": "설치한 다음 /{{command}}을(를) 다시 실행하세요.", "installThenSend": "설치한 다음 메시지를 다시 보내세요.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 4605a3fddb3e..dceb2e3117c8 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17308,6 +17308,7 @@ "signInThenRunCommand": "请登录,然后重新运行 /{{command}}。", "signInThenSend": "请登录,然后重新发送您的消息。", "providerMissing": "{{agent}} 未安装。", + "installFirst": "请先安装。", "installThenRetry": "请安装,然后重试。", "installThenRunCommand": "请安装,然后重新运行 /{{command}}。", "installThenSend": "请安装,然后重新发送您的消息。", diff --git a/src/renderer/src/lib/active-agent-note-send-diagnostics.ts b/src/renderer/src/lib/active-agent-note-send-diagnostics.ts index 155dec17e067..a7ef472e778c 100644 --- a/src/renderer/src/lib/active-agent-note-send-diagnostics.ts +++ b/src/renderer/src/lib/active-agent-note-send-diagnostics.ts @@ -78,5 +78,7 @@ function codeForStatus( return 'terminal-send-refused' case 'partial-submit-failed': return 'submit-send-error' + case 'not-taken': + return 'session-message-not-taken' } } diff --git a/src/renderer/src/lib/active-agent-note-send-result.ts b/src/renderer/src/lib/active-agent-note-send-result.ts index 30bb4515a5e1..33814fadfb4d 100644 --- a/src/renderer/src/lib/active-agent-note-send-result.ts +++ b/src/renderer/src/lib/active-agent-note-send-result.ts @@ -8,6 +8,8 @@ export type ActiveAgentNotesSendStatus = | 'not-ready' | 'not-writable' | 'partial-submit-failed' + /** The chat's agent never took the message: its start failed, or the chat closed first. */ + | 'not-taken' export type ActiveAgentNotesSendFailureCode = | 'empty' @@ -34,6 +36,7 @@ export type ActiveAgentNotesSendFailureCode = | 'runtime-unverifiable' | 'runtime-timeout' | 'session-outbox-unsaved' + | 'session-message-not-taken' export type ActiveAgentNotesSendResult = { status: ActiveAgentNotesSendStatus @@ -72,6 +75,9 @@ export function activeAgentNotesSendFailureMessage( case 'not-writable': message = `The ${target} agent did not accept the notes.` break + case 'not-taken': + message = `The ${target} agent did not take the notes; its chat says why.` + break case 'partial-submit-failed': message = options.explicitTarget ? 'The notes may already be pasted in the selected terminal, but Orca could not submit them.' diff --git a/src/renderer/src/lib/agent-message-send-structured-delivery.test.ts b/src/renderer/src/lib/agent-message-send-structured-delivery.test.ts new file mode 100644 index 000000000000..e1d40d4524b9 --- /dev/null +++ b/src/renderer/src/lib/agent-message-send-structured-delivery.test.ts @@ -0,0 +1,279 @@ +// @vitest-environment happy-dom +// Notes or annotations sent to an existing chat at rest: its agent starts on that message, which +// may fail. The source clears what it sent only once the agent took it, and a message not taken +// stays in the chat without a Retry there, since the source still holds it. + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { StoreApi } from 'zustand/vanilla' +import { structuredAgentSessionPaneKey } from '../../../shared/structured-agent-session-projection' +import { + reconcileStructuredAgentSessionOutbox, + stageStructuredAgentSessionOutboxEntryForSend +} from '../../../shared/structured-agent-session-outbox' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { + commitStructuredAgentSessionOutbox, + getStructuredAgentSessionOutbox, + mutateStructuredAgentSessionLaunchPrompt +} from '@/components/native-chat/structured-agent-session-outbox-storage' +import { structuredAgentSessionDeliveryNotices } from '@/components/native-chat/structured-agent-session-delivery-notices' +import type { AppState } from '@/store/types' +import { createUIStore } from '@/store/slices/ui-slice-test-harness' +import { sendMessageToAgent } from './agent-message-send' +import { + FIRST_START_FAILS, + firstMessageStream, + play, + type FirstMessageStream +} from './structured-agent-session-launch-prompt-test-support' + +const client = vi.hoisted(() => ({ call: vi.fn(), subscribe: vi.fn() })) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: client.call, + subscribeStructuredAgentSession: client.subscribe +})) + +vi.mock('./structured-agent-session-launch', () => ({ + relaunchFailedStructuredAgentSessionForMessage: vi.fn() +})) + +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), message: vi.fn() })) +vi.mock('sonner', () => ({ toast })) +vi.mock('@/lib/telemetry', () => ({ track: vi.fn() })) + +const WORKTREE = 'wt-1' +let session = 0 +let sessionId = '' + +/** The one message the code under test queued on the chat. */ +function sentEntry() { + const [entry] = getStructuredAgentSessionOutbox(sessionId) + return entry +} + +/** The open chat's drain putting the queued message on the wire, as it does for any send. */ +function chatSendsIt(): void { + mutateStructuredAgentSessionLaunchPrompt(sessionId, sentEntry().clientMessageId, (entry) => + stageStructuredAgentSessionOutboxEntryForSend(entry, Date.now()) + ) +} + +/** The open chat folding the journal's answer into its outbox. */ +function chatReconciles(submission: AgentJournalSubmission): void { + commitStructuredAgentSessionOutbox( + sessionId, + reconcileStructuredAgentSessionOutbox(getStructuredAgentSessionOutbox(sessionId), [submission]) + ) +} + +function rejectedSubmission(): AgentJournalSubmission { + return { + clientMessageId: sentEntry().clientMessageId, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'rejected', + providerItemId: null, + reason: "Claude isn't installed.", + rejection: { kind: 'providerMissing' }, + submittedAt: 1, + resolvedAt: 2 + } +} + +function settledState(promise: Promise): () => T | 'unsettled' { + let state: T | 'unsettled' = 'unsettled' + void promise.then((value) => { + state = value + }) + return () => state +} + +async function sendNotes(): Promise<{ + result: Promise>> + stream: FirstMessageStream +}> { + const opened = firstMessageStream(client, () => sentEntry().clientMessageId) + const result = sendMessageToAgent({ + worktreeId: WORKTREE, + prompt: 'Review these notes', + target: { kind: 'structured-session', sessionId } + }) + const stream = await opened + chatSendsIt() + return { result, stream } +} + +beforeEach(() => { + session += 1 + sessionId = `claude-${session}` + client.call.mockReset() + client.subscribe.mockReset() + toast.success.mockReset() + toast.error.mockReset() +}) + +afterEach(() => { + localStorage.clear() +}) + +describe('notes sent to a chat whose agent starts on them', () => { + it('count as sent only once a retried start takes them', async () => { + const { result, stream } = await sendNotes() + const sent = settledState(result) + + play(stream, FIRST_START_FAILS.retriedThenTaken.slice(0, -1)) + await Promise.resolve() + expect(sent()).toBe('unsettled') + + play(stream, FIRST_START_FAILS.retriedThenTaken.slice(-1)) + await expect(result).resolves.toEqual({ status: 'sent' }) + expect(stream.open()).toBe(false) + }) + + it('wait through a lost answer that a late echo proves taken', async () => { + const { result, stream } = await sendNotes() + play(stream, FIRST_START_FAILS.unknownThenTaken) + await expect(result).resolves.toEqual({ status: 'sent' }) + }) + + it.each([ + ['rejected after its tries', FIRST_START_FAILS.rejectedAfterTries], + ['withdrawn when the chat closes mid-wait', FIRST_START_FAILS.chatClosed] + ])('are not sent when %s, and stay in the chat as its source', async (_case, changes) => { + const { result, stream } = await sendNotes() + play(stream, changes) + + await expect(result).resolves.toEqual({ + status: 'not-taken', + code: 'session-message-not-taken' + }) + expect(sentEntry()).toMatchObject({ source: 'surface' }) + }) + + it('leave the chat no Retry once rejected, and say where to send them again', async () => { + const { result } = await sendNotes() + chatReconciles(rejectedSubmission()) + + await expect(result).resolves.toMatchObject({ status: 'not-taken' }) + const notice = structuredAgentSessionDeliveryNotices( + getStructuredAgentSessionOutbox(sessionId), + 'Claude', + vi.fn(), + [rejectedSubmission()], + [], + new Set() + ).get(agentJournalSubmissionKey(sentEntry().clientMessageId)) + expect(notice?.onRetry).toBeUndefined() + expect(notice?.text).toBe( + "Not sent: Claude isn't installed. Install it first. Send it again from where you started it." + ) + }) + + it('are not sent when the host refuses the send before recording it', async () => { + const { result } = await sendNotes() + mutateStructuredAgentSessionLaunchPrompt(sessionId, sentEntry().clientMessageId, (entry) => ({ + ...entry, + state: 'queued', + lastFailure: { kind: 'refused', code: 'agent_session_operation_capacity' } + })) + + await expect(result).resolves.toMatchObject({ status: 'not-taken' }) + }) +}) + +// The picker lists a chat once it has a status row; its agent may since have stopped, and starts +// again on the next message the same way. +describe('annotations sent from the agent picker to a chat whose agent starts on them', () => { + const chatTabId = 'tab-chat' + + function pickerStore(): StoreApi { + const store = createUIStore() + const now = Date.now() + const paneKey = structuredAgentSessionPaneKey(chatTabId, sessionId) + store.setState({ + agentStatusByPaneKey: { + [paneKey]: { + state: 'done', + prompt: 'previous', + updatedAt: now, + stateStartedAt: now, + agentType: 'claude', + paneKey, + stateHistory: [] + } + }, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + ptyIdsByTabId: {}, + unifiedTabsByWorktree: { + [WORKTREE]: [ + { + id: chatTabId, + entityId: sessionId, + groupId: 'group-1', + worktreeId: WORKTREE, + contentType: 'agent-session', + agentSessionAgent: 'claude', + label: 'Claude Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: Date.now() + } + ] + } + }) + return store + } + + async function sendFromPicker(onPromptDelivered: () => void) { + const store = pickerStore() + store.getState().openAgentSendPopoverTargetMode({ + id: 'send-1', + worktreeId: WORKTREE, + source: 'browser-annotations', + prompt: 'Fix these annotations', + label: 'Browser annotations', + launchSource: 'notes_send', + onPromptDelivered + }) + const opened = firstMessageStream(client, () => sentEntry().clientMessageId) + const sent = store + .getState() + .sendPromptToSidebarAgentTarget(structuredAgentSessionPaneKey(chatTabId, sessionId)) + return { sent, stream: await opened } + } + + it('are cleared once, after the retried start takes them', async () => { + const onPromptDelivered = vi.fn() + const { sent, stream } = await sendFromPicker(onPromptDelivered) + + play(stream, FIRST_START_FAILS.retriedThenTaken.slice(0, -1)) + await Promise.resolve() + expect(onPromptDelivered).not.toHaveBeenCalled() + expect(toast.success).not.toHaveBeenCalled() + + play(stream, FIRST_START_FAILS.retriedThenTaken.slice(-1)) + await expect(sent).resolves.toBe(true) + expect(onPromptDelivered).toHaveBeenCalledTimes(1) + expect(toast.success).toHaveBeenCalledWith('Sent to Claude') + }) + + it.each([ + ['rejected after its tries', FIRST_START_FAILS.rejectedAfterTries], + ['withdrawn when the chat closes mid-wait', FIRST_START_FAILS.chatClosed] + ])('are kept, with a failure notice, when %s', async (_case, changes) => { + const onPromptDelivered = vi.fn() + const { sent, stream } = await sendFromPicker(onPromptDelivered) + play(stream, changes) + + await expect(sent).resolves.toBe(false) + expect(onPromptDelivered).not.toHaveBeenCalled() + expect(toast.success).not.toHaveBeenCalled() + expect(toast.error).toHaveBeenCalledWith("Couldn't send to Claude", { + description: + 'The selected agent did not take the notes; its chat says why. (session-message-not-taken)' + }) + }) +}) diff --git a/src/renderer/src/lib/agent-message-send.ts b/src/renderer/src/lib/agent-message-send.ts index 03f92bc22f16..5cdd467f40d1 100644 --- a/src/renderer/src/lib/agent-message-send.ts +++ b/src/renderer/src/lib/agent-message-send.ts @@ -3,6 +3,7 @@ import type { AgentMessageTarget } from './agent-message-target' import { sendNotesToActiveAgentSession } from './active-agent-note-send' import { relaunchFailedStructuredAgentSessionForMessage } from './structured-agent-session-launch' import { appendStructuredAgentSessionOutboxMessage } from '@/components/native-chat/structured-agent-session-outbox-storage' +import { awaitStructuredSourcedMessageTaken } from './structured-agent-session-sourced-message-taken' export type { AgentMessageTarget } from './agent-message-target' @@ -25,10 +26,15 @@ export async function sendMessageToAgent(args: { }) } // Why: queued on the chat's own outbox, as its composer does, so the message shows in the - // chat and a failed send stays there with Retry. The open chat delivers it. - if (!appendStructuredAgentSessionOutboxMessage(target.sessionId, prompt)) { + // chat. The open chat delivers it; marked as this caller's, a failed send stays there without + // a Retry, since the caller keeps what it sent and sends it again. + const entry = appendStructuredAgentSessionOutboxMessage(target.sessionId, prompt, [], 'surface') + if (!entry) { return { status: 'not-writable', code: 'session-outbox-unsaved' } } relaunchFailedStructuredAgentSessionForMessage(worktreeId, target.sessionId) - return { status: 'sent' } + // A chat at rest starts its agent on this message, which may fail: sent means the agent took it. + return (await awaitStructuredSourcedMessageTaken(target.sessionId, entry.clientMessageId)) + ? { status: 'sent' } + : { status: 'not-taken', code: 'session-message-not-taken' } } diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt-handover.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt-handover.ts deleted file mode 100644 index e941f4b6b30b..000000000000 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt-handover.ts +++ /dev/null @@ -1,92 +0,0 @@ -// When a launch prompt counts as delivered: once the agent has taken it. A chat's first message -// starts its agent, and the host answers the send when it accepts the message, before that start; -// a caller that writes outward on delivery (GitHub replies, cleared notes) must not do so for an -// agent that never started. So the wait follows the message to its own final state, read from the -// session's publication: no new wire field, and no timer of its own. A start the host retries -// keeps the message queued, and the retry schedule ends in a verdict. - -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import { subscribeStructuredAgentSession } from '@/runtime/structured-agent-session-client' - -type LaunchPromptVerdict = 'taken' | 'not-taken' | 'waiting' - -export function structuredLaunchPromptVerdict( - submission: Pick -): LaunchPromptVerdict { - switch (submission.dispatchState) { - case 'accepted': - return 'taken' - // Unknown is a hand-over whose answer was lost, which a late echo can still prove taken. - case 'pending': - case 'unknown': - return 'waiting' - // A start that failed for good, or a chat closed first. - case 'rejected': - return 'not-taken' - } -} - -// One read per launch prompt, whoever asks. -const inFlight = new Map>() - -/** Whether the chat's agent took the launch prompt: only accepted or rejected answer it. Anything - * that ends the read first (the stream ending or failing) answers no: the caller keeps what it - * would have written. */ -export function awaitStructuredLaunchPromptTaken( - sessionId: string, - clientMessageId: string -): Promise { - const key = `${sessionId}:${clientMessageId}` - const existing = inFlight.get(key) - if (existing) { - return existing - } - const read = readUntilFinal(sessionId, clientMessageId).finally(() => inFlight.delete(key)) - inFlight.set(key, read) - return read -} - -function readUntilFinal(sessionId: string, clientMessageId: string): Promise { - return new Promise((resolve) => { - let settled = false - let unsubscribe: (() => void) | null = null - const finish = (taken: boolean): void => { - if (settled) { - return - } - settled = true - unsubscribe?.() - resolve(taken) - } - const read = (submissions: readonly AgentJournalSubmission[]): void => { - const submission = submissions.find((entry) => entry.clientMessageId === clientMessageId) - const verdict = submission ? structuredLaunchPromptVerdict(submission) : 'waiting' - if (verdict !== 'waiting') { - finish(verdict === 'taken') - } - } - subscribeStructuredAgentSession( - { kind: 'local' }, - { sessionId }, - (event) => { - if (event.type === 'snapshot' || event.type === 'reset') { - read(event.page.submissions) - } else if (event.type === 'batch') { - read(event.batch.submissions) - } else { - finish(false) - } - }, - () => finish(false), - () => finish(false) - ).then( - (handle) => { - unsubscribe = handle.unsubscribe - if (settled) { - handle.unsubscribe() - } - }, - () => finish(false) - ) - }) -} diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt-test-support.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt-test-support.ts index 71a7a3a109c6..415e33b98614 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt-test-support.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt-test-support.ts @@ -73,21 +73,22 @@ export type FirstMessageStream = { * Answers the send as accepted-but-queued and hands back the stream the host then publishes: a * snapshot first, as the host's subscribe always opens, then batches. `opened` is what the message * already is when the reader subscribes, e.g. taken in the gap between the send's answer and then. + * A message whose id is minted by the code under test names it once the reader subscribes. */ export function firstMessageStream( client: LaunchPromptClientMock, - clientMessageId: string, + clientMessageId: string | (() => string), opened: Partial = {} ): Promise { - const answered: AgentJournalSubmission = { clientMessageId, ...QUEUED } - let current: AgentJournalSubmission = { ...answered, ...opened } - client.call.mockResolvedValue({ + const idNow = (): string => + typeof clientMessageId === 'function' ? clientMessageId() : clientMessageId + client.call.mockImplementation(async () => ({ ok: true, replayed: false, fence: 1, cursor: { epoch: 'epoch-1', sequence: 1 }, - value: { clientMessageId, submission: answered } - }) + value: { clientMessageId: idNow(), submission: { clientMessageId: idNow(), ...QUEUED } } + })) return new Promise((resolve) => { client.subscribe.mockImplementation( async ( @@ -95,6 +96,7 @@ export function firstMessageStream( _params: unknown, onEvent: (e: AgentSessionSubscribeEvent) => void ) => { + let current: AgentJournalSubmission = { clientMessageId: idNow(), ...QUEUED, ...opened } let subscribed = true const publish = (submission: AgentJournalSubmission): void => onEvent({ diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts index 6562951fb758..4c7f891fbfc3 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts @@ -14,7 +14,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ })) import { settleStructuredAgentLaunchPrompt } from './structured-agent-session-launch-prompt' -import { awaitStructuredLaunchPromptTaken } from './structured-agent-session-launch-prompt-handover' +import { awaitStructuredSourcedMessageTaken } from './structured-agent-session-sourced-message-taken' import { FIRST_START_FAILS, firstMessageStream, @@ -128,8 +128,8 @@ describe('settleStructuredAgentLaunchPrompt', () => { it('reads one launch prompt once, however many ask', async () => { const stream = firstMessageStream(mocks, 'message-1') - const first = awaitStructuredLaunchPromptTaken('session-1', 'message-1') - const second = awaitStructuredLaunchPromptTaken('session-1', 'message-1') + const first = awaitStructuredSourcedMessageTaken('session-1', 'message-1') + const second = awaitStructuredSourcedMessageTaken('session-1', 'message-1') play(await stream, FIRST_START_FAILS.retriedThenTaken) await expect(Promise.all([first, second])).resolves.toEqual([true, true]) diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index 57ff38f89ff0..6e5804d19e90 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -18,9 +18,9 @@ import { import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { createBrowserUuid } from '@/lib/browser-uuid' import { - awaitStructuredLaunchPromptTaken, - structuredLaunchPromptVerdict -} from '@/lib/structured-agent-session-launch-prompt-handover' + awaitStructuredSourcedMessageTaken, + structuredSourcedMessageVerdict +} from '@/lib/structured-agent-session-sourced-message-taken' export type StructuredPromptDeliveryResult = { delivered: boolean @@ -198,13 +198,13 @@ export function settleStructuredAgentLaunchPrompt(args: { (await dispatch.promise) || (answer !== undefined && answer !== 'queued' && answer.dispatchState === 'unknown') const verdict = - answer === undefined || answer === 'queued' ? null : structuredLaunchPromptVerdict(answer) + answer === undefined || answer === 'queued' ? null : structuredSourcedMessageVerdict(answer) const delivered = admitted && (answer === 'queued' || verdict === 'taken' || (verdict !== 'not-taken' && - (await awaitStructuredLaunchPromptTaken(entry.sessionId, entry.clientMessageId)))) + (await awaitStructuredSourcedMessageTaken(entry.sessionId, entry.clientMessageId)))) if (delivered) { args.options.onPromptDelivered?.() } diff --git a/src/renderer/src/lib/structured-agent-session-sourced-message-taken.ts b/src/renderer/src/lib/structured-agent-session-sourced-message-taken.ts new file mode 100644 index 000000000000..603847178f1f --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-sourced-message-taken.ts @@ -0,0 +1,128 @@ +// When a message sent to a chat from somewhere else counts as delivered: once the agent has taken +// it. A launch's prompt, or review notes sent to a chat at rest, start its agent, and the host +// answers the send when it accepts the message, before that start; a source that writes outward on +// delivery (GitHub replies, cleared notes) must not do so for an agent that never started. So the +// wait follows the message to its own final state, read from the session's publication and the +// chat's outbox: no new wire field, and no timer of its own. A start the host retries keeps the +// message queued, and the retry schedule ends in a verdict. + +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import type { AgentSessionQueuedMessage } from '../../../shared/agent-session-queued-message-wire' +import type { StructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox' +import { structuredAgentSessionEntryHeldForRetry } from '../../../shared/structured-agent-session-outbox-admission' +import { subscribeToStructuredAgentSessionOutboxCommits } from '@/components/native-chat/structured-agent-session-outbox-storage' +import { subscribeStructuredAgentSession } from '@/runtime/structured-agent-session-client' + +type SourcedMessageVerdict = 'taken' | 'not-taken' | 'waiting' + +export function structuredSourcedMessageVerdict( + submission: Pick +): SourcedMessageVerdict { + switch (submission.dispatchState) { + case 'accepted': + return 'taken' + // Unknown is a hand-over whose answer was lost, which a late echo can still prove taken. + case 'pending': + case 'unknown': + return 'waiting' + // A start that failed for good, or a chat closed first. + case 'rejected': + return 'not-taken' + } +} + +/** The outbox's word on the message: one it holds as not sent waits only for a Retry its source + * owns. Gone from the outbox, the journal has it. */ +function outboxVerdict( + entries: readonly StructuredAgentSessionOutboxEntry[], + clientMessageId: string +): SourcedMessageVerdict { + const entry = entries.find((candidate) => candidate.clientMessageId === clientMessageId) + return entry && (entry.state === 'rejected' || structuredAgentSessionEntryHeldForRetry(entry)) + ? 'not-taken' + : 'waiting' +} + +// One read per message, whoever asks. +const inFlight = new Map>() + +/** Whether the chat's agent took the message: only accepted, held as a draft behind a running turn, + * or not sent answer it. Anything that ends the read first (the stream ending or failing) answers + * no: the source keeps what it would have written. */ +export function awaitStructuredSourcedMessageTaken( + sessionId: string, + clientMessageId: string +): Promise { + const key = `${sessionId}:${clientMessageId}` + const existing = inFlight.get(key) + if (existing) { + return existing + } + const read = readUntilFinal(sessionId, clientMessageId).finally(() => inFlight.delete(key)) + inFlight.set(key, read) + return read +} + +function readUntilFinal(sessionId: string, clientMessageId: string): Promise { + return new Promise((resolve) => { + let settled = false + let unsubscribe: (() => void) | null = null + const finish = (taken: boolean): void => { + if (settled) { + return + } + settled = true + stopOutbox() + unsubscribe?.() + resolve(taken) + } + const stopOutbox = subscribeToStructuredAgentSessionOutboxCommits(sessionId, (entries) => { + if (outboxVerdict(entries, clientMessageId) === 'not-taken') { + finish(false) + } + }) + const read = ( + submissions: readonly AgentJournalSubmission[], + queued: readonly AgentSessionQueuedMessage[] | null | undefined + ): void => { + // A draft the host holds behind a running turn is that live agent's, and its card owns the + // rest; a returned one failed to send. + const draft = queued?.find((candidate) => candidate.messageId === clientMessageId) + if (draft) { + finish(draft.state === 'waiting') + return + } + const submission = submissions.find( + (entry) => + entry.clientMessageId === clientMessageId || entry.queuedMessageId === clientMessageId + ) + const verdict = submission ? structuredSourcedMessageVerdict(submission) : 'waiting' + if (verdict !== 'waiting') { + finish(verdict === 'taken') + } + } + subscribeStructuredAgentSession( + { kind: 'local' }, + { sessionId }, + (event) => { + if (event.type === 'snapshot' || event.type === 'reset') { + read(event.page.submissions, event.queuedMessages ?? event.page.queuedMessages) + } else if (event.type === 'batch') { + read(event.batch.submissions, event.queuedMessages) + } else { + finish(false) + } + }, + () => finish(false), + () => finish(false) + ).then( + (handle) => { + unsubscribe = handle.unsubscribe + if (settled) { + handle.unsubscribe() + } + }, + () => finish(false) + ) + }) +} diff --git a/src/renderer/src/store/slices/ui-agent-send-target.test.ts b/src/renderer/src/store/slices/ui-agent-send-target.test.ts index 61f98a2afbad..5eb1b664e0d3 100644 --- a/src/renderer/src/store/slices/ui-agent-send-target.test.ts +++ b/src/renderer/src/store/slices/ui-agent-send-target.test.ts @@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({ sendNotesToActiveAgentSession: vi.fn(), appendStructuredAgentSessionOutboxMessage: vi.fn(), relaunchFailedStructuredAgentSessionForMessage: vi.fn(), + awaitStructuredSourcedMessageTaken: vi.fn(), track: vi.fn(), toastMessage: vi.fn(), toastSuccess: vi.fn(), @@ -30,6 +31,10 @@ vi.mock('@/components/native-chat/structured-agent-session-outbox-storage', () = appendStructuredAgentSessionOutboxMessage: mocks.appendStructuredAgentSessionOutboxMessage })) +vi.mock('@/lib/structured-agent-session-sourced-message-taken', () => ({ + awaitStructuredSourcedMessageTaken: mocks.awaitStructuredSourcedMessageTaken +})) + vi.mock('@/lib/structured-agent-session-launch', () => ({ relaunchFailedStructuredAgentSessionForMessage: mocks.relaunchFailedStructuredAgentSessionForMessage @@ -58,6 +63,8 @@ beforeEach(() => { mocks.appendStructuredAgentSessionOutboxMessage.mockReset() mocks.appendStructuredAgentSessionOutboxMessage.mockReturnValue({ clientMessageId: 'queued' }) mocks.relaunchFailedStructuredAgentSessionForMessage.mockReset() + mocks.awaitStructuredSourcedMessageTaken.mockReset() + mocks.awaitStructuredSourcedMessageTaken.mockResolvedValue(true) mocks.track.mockReset() mocks.toastMessage.mockReset() mocks.toastSuccess.mockReset() @@ -389,8 +396,11 @@ describe('createUISlice agent send target mode', () => { expect(mocks.appendStructuredAgentSessionOutboxMessage).toHaveBeenCalledWith( 'claude_1', - 'Review this' + 'Review this', + [], + 'surface' ) + expect(mocks.awaitStructuredSourcedMessageTaken).toHaveBeenCalledWith('claude_1', 'queued') expect(mocks.relaunchFailedStructuredAgentSessionForMessage).toHaveBeenCalledWith( worktreeId, 'claude_1' diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index 83322a306729..fbe783b15bce 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -24,6 +24,7 @@ export const AGENT_SESSION_FAILURE_COPY = { signInThenRunCommand: 'Sign in, then run /{{command}} again.', signInThenSend: 'Sign in, then send your message again.', providerMissing: "{{agent}} isn't installed.", + installFirst: 'Install it first.', installThenRetry: 'Install it, then retry.', installThenRunCommand: 'Install it, then run /{{command}} again.', installThenSend: 'Install it, then send your message again.', diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index aaefb52b3919..5c1a74360239 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -68,6 +68,9 @@ export type AgentSessionFailureWordsContext = { /** Orca starts it again on its own, so the words leave out even when to try again. Implies * `retryControl`. */ orcaRetries?: boolean + /** Where the message was sent from sends it again, so the words name only the person's own step + * first. Implies `retryControl`. */ + sourceRetries?: boolean } /** Person-facing provider text is quoted, but bounded so the sentence stays one. */ @@ -181,11 +184,13 @@ const FAILURE_SENTENCES = { providerMissing: (context, _fact, _surface, say) => joinSentences([ say('providerMissing', agent(say, context)), - context.retryControl - ? say('installThenRetry') - : context.command - ? say('installThenRunCommand', { command: context.command }) - : say('installThenSend') + context.sourceRetries + ? say('installFirst') + : context.retryControl + ? say('installThenRetry') + : context.command + ? say('installThenRunCommand', { command: context.command }) + : say('installThenSend') ]), historyTooLarge: (_context, _fact, _surface, say) => joinSentences([say('historyTooLarge'), say('startNewChat')]), diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index d7ee2f59d3dd..0f4cf857b770 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -23,6 +23,8 @@ export type StructuredAgentSessionOutboxState = | 'unconfirmed' | 'rejected' +export type StructuredAgentSessionOutboxSource = 'launch' | 'surface' + export type StructuredAgentSessionOutboxEntry = { clientMessageId: string sessionId: string @@ -32,7 +34,9 @@ export type StructuredAgentSessionOutboxEntry = { queuedAt: number lastAttemptAt: number | null retryAfterUnknownSubmittedAt: number | null - source?: 'launch' + /** Sent from outside the chat, which owns sending it again: a launch's prompt (`launch`), or a + * message another surface sent to this chat (`surface`), such as review notes. */ + source?: StructuredAgentSessionOutboxSource /** A Stop landed after this queue send went out: only the user's Retry sends it again, never the * drain, the unconfirmed probe or an owner change, which would start a turn the user stopped. */ outlivedStop?: true @@ -186,8 +190,13 @@ export function requeueStructuredAgentSessionSendRefusal( const ownerExited = refusal.code === 'agent_session_ownership_unknown' && agentSessionOwnerVerdictAllowsFreshOperationId(refusal.details?.ownerVerdict) + // Its source sends it again as a new message, never this entry, so its id stays what it waits on. + if (entry.source !== undefined && refusalSettled) { + return { ...entry, state: 'rejected' } + } if ( !(refusalSettled || ownerExited) || + entry.source !== undefined || retainOperationId || entry.state === 'unconfirmed' || entry.retryAfterUnknownSubmittedAt !== null @@ -295,7 +304,7 @@ export function parseStructuredAgentSessionOutboxEntry( typeof entry.retryAfterUnknownSubmittedAt === 'number' ? entry.retryAfterUnknownSubmittedAt : null, - ...(entry.source === 'launch' ? { source: 'launch' as const } : {}), + ...(entry.source === 'launch' || entry.source === 'surface' ? { source: entry.source } : {}), ...parseStructuredAgentSessionOutboxQueueFields(entry), ...(lastFailure ? { lastFailure } : {}) } From fba2e4c971b615187d0864ec16dd3488248acac0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:05:33 -0700 Subject: [PATCH 39/84] test(native-chat): a refused message from another surface keeps its id --- .../agent-session-refusal-retry.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/shared/agent-session-refusal-retry.test.ts b/src/shared/agent-session-refusal-retry.test.ts index 79020e5695f0..f132d10a6e7a 100644 --- a/src/shared/agent-session-refusal-retry.test.ts +++ b/src/shared/agent-session-refusal-retry.test.ts @@ -103,3 +103,24 @@ describe("the owner's verdict decides a retry's operation id only as a floor", ( expect(agentSessionOwnerVerdictAllowsFreshOperationId('exited', 'unverifiable')).toBe(true) }) }) + +describe('a refused message sent from another surface', () => { + const fromNotes = { ...entry, source: 'surface' as const } + + it('keeps its id, since only its source sends it again', () => { + const refused = requeueStructuredAgentSessionSendRefusal( + fromNotes, + { kind: 'refused', code: 'agent_session_operation_invalid' }, + () => 'message-2' + ) + expect(refused).toMatchObject({ clientMessageId: 'message-1', state: 'rejected' }) + expect(retried('exited')).toMatchObject({ clientMessageId: 'message-2' }) + expect( + requeueStructuredAgentSessionSendRefusal( + fromNotes, + storedOwnershipRefusal('exited'), + () => 'message-2' + ) + ).toMatchObject({ clientMessageId: 'message-1', state: 'queued' }) + }) +}) From 58ad6d364c6c922ceb316529757bfd3b63bbd68e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:10:46 -0700 Subject: [PATCH 40/84] test(native-chat): type the held refusal fixture as a refusal --- .../structured-agent-session-delivery-notices.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index a259b2b2a7ca..827160d94c11 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -600,7 +600,7 @@ describe('the notice on a message sent from elsewhere that was not sent', () => }) it('offers no Retry on notes the host refused and the chat holds, since their source sends them again', () => { - const refused = { kind: 'refused' as const, code: 'agent_session_operation_capacity' } + const refused = { kind: 'refused', code: 'agent_session_operation_capacity' } as const const notices = structuredAgentSessionDeliveryNotices( [ entry('notes', { source: 'surface', state: 'queued', lastFailure: refused }), From 96c45024bf5fa3051d8a29b3636407b145286075 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:22:25 -0700 Subject: [PATCH 41/84] test(native-chat): read the started chat's fence instead of the at-rest one The shared attach harness now creates a chat at rest (fence 1) and starts its agent the way production does, which moves the fence to 2. Three suites still wrote or expected fence 1 and failed; they now read the record's fence. --- .../structured-agent-session-queued-gate.test.ts | 14 +++++++------- ...tructured-agent-session-queued-messages.test.ts | 5 +++-- .../structured-agent-session-send.test.ts | 3 ++- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts index 26eaa8b8ab59..e6c23f512157 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts @@ -49,9 +49,10 @@ describe('the one queue gate', () => { } const draftId = queued.value.queued.messageId // An unfinished /clear changed nothing the chat reads, so it refuses no send. - await store.setConversationCommand(SESSION, 1, { + const fence = store.getRecord(SESSION)!.lease.runtimeFence + await store.setConversationCommand(SESSION, fence, { command: 'clear', - runtimeFence: 1, + runtimeFence: fence, operationId: hostTestOperationId(), callerKey: CALLER.callerKey, phase: 'prepared', @@ -69,6 +70,7 @@ describe('the one queue gate', () => { } const draftId = queued.value.queued.messageId const journal = host.collaboratorsForTests().sessions.get(SESSION)!.journal + const fence = store.getRecord(SESSION)!.lease.runtimeFence await journal.appendItem( { provider: 'orca', clientMessageId: 'prompt-1' }, { @@ -78,7 +80,7 @@ describe('the one queue gate', () => { options: [], resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) expect(await sendNow(draftId)).toMatchObject({ ok: false, @@ -86,9 +88,7 @@ describe('the one queue gate', () => { }) // Read in place: the gate runs on every admission and drain step. const snapshot = vi.spyOn(journal, 'snapshot') - expect(structuredQueueHold({ journal, record: store.getRecord(SESSION), fence: 1 })).toBe( - 'prompt' - ) + expect(structuredQueueHold({ journal, record: store.getRecord(SESSION), fence })).toBe('prompt') expect(snapshot).not.toHaveBeenCalled() snapshot.mockRestore() await journal.appendItem( @@ -105,7 +105,7 @@ describe('the one queue gate', () => { resolvedAt: 1 } }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) // The turn still runs (`working`), which Send-now alone may override. expect(await submission(working)).toMatchObject({ dispatchState: 'pending' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts index 33de806ff3b7..48fe813f17a5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.test.ts @@ -709,9 +709,10 @@ describe('/clear', () => { throw new Error('expected a queued receipt') } const draftId = queued.value.queued.messageId - await store.setConversationCommand(SESSION, 1, { + const fence = store.getRecord(SESSION)!.lease.runtimeFence + await store.setConversationCommand(SESSION, fence, { command: 'clear', - runtimeFence: 1, + runtimeFence: fence, operationId: hostTestOperationId(), callerKey: CALLER.callerKey, phase: 'prepared', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts index f0163b22791d..d643f2be5252 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts @@ -82,7 +82,8 @@ describe('send', () => { expect(dispatch).toHaveBeenCalledTimes(1) const page = await host.history({ sessionId: SESSION, direction: 'tail' }) expect(page.ok && page.page.items).toHaveLength(1) - expect(page.ok && page.page.fence).toBe(1) + // The harness creates the chat at rest (fence 1), then starts its agent at 2. + expect(page.ok && page.page.fence).toBe(2) expect(page.page.hostNow).toBe(NOW) expect(page.providerSession).toEqual({ key: 'session_id', id: THREAD }) }) From 2bf5b0ee49894489a3a9022f9bdc5f4225c06be9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:25:01 -0700 Subject: [PATCH 42/84] fix(i18n): name the agent's prompt in each catalog's own terms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The launch-prompt-not-taken toast used 代理/提示 in Chinese (代理 also reads as proxy), l'invite in French and el mensaje in Spanish. The catalogs say 智能体/提示词, le prompt and el prompt for an agent's prompt everywhere else. --- src/renderer/src/i18n/locales/es.json | 2 +- src/renderer/src/i18n/locales/fr.json | 2 +- src/renderer/src/i18n/locales/zh.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 7546a38e9d26..197c6a229d25 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15181,7 +15181,7 @@ "startRetrying": "Orca lo intentará de nuevo en breve.", "launchPromptSendAgain": "Vuelve a enviarlo desde donde lo iniciaste.", "launchPromptNotSent": "No enviado: {{reason}}", - "launchPromptNotTaken": "El agente no recibió el mensaje, así que no se marcó nada como enviado. Vuelve a enviarlo desde aquí.", + "launchPromptNotTaken": "El agente no recibió el prompt, así que no se marcó nada como enviado. Vuelve a enviarlo desde aquí.", "deliveryUnconfirmed": "Entrega sin confirmar — revisa el chat antes de reintentar", "subagents": { "unnamed": "Subagente" diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index a77a3808de22..997450ed2ffe 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17866,7 +17866,7 @@ "startRetrying": "Orca réessaiera sous peu.", "launchPromptSendAgain": "Renvoyez-le depuis l'endroit où vous l'avez lancé.", "launchPromptNotSent": "Non envoyé : {{reason}}", - "launchPromptNotTaken": "L'agent n'a pas pris l'invite, donc rien n'a été marqué comme envoyé. Renvoyez-la d'ici.", + "launchPromptNotTaken": "L'agent n'a pas pris le prompt, donc rien n'a été marqué comme envoyé. Renvoyez-le d'ici.", "deliveryUnconfirmed": "Livraison non confirmée — vérifiez le chat avant de réessayer", "queuedMessages": { "alreadySent": "Ce message a déjà été envoyé.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index dceb2e3117c8..6903ac261e87 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17767,7 +17767,7 @@ "startRetrying": "Orca 将很快重试。", "launchPromptSendAgain": "请从你发起它的地方重新发送。", "launchPromptNotSent": "未发送:{{reason}}", - "launchPromptNotTaken": "代理没有接收提示,因此没有标记为已发送。请从这里重新发送。", + "launchPromptNotTaken": "智能体没有接收提示词,因此没有标记为已发送。请从这里重新发送。", "deliveryUnconfirmed": "无法确认送达 — 重试前请检查聊天", "queuedMessages": { "alreadySent": "此消息已发送。", From 5fff7935f2d2bd98dc94b540d9b11de466834b92 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:28:44 -0700 Subject: [PATCH 43/84] fix(native-chat): a Stop keeps a launch prompt as not sent instead of pasting it into the composer Stop while a launch prompt waited on a retrying start withdrew it, and the chat dropped the entry and pasted the prompt into the composer. Its source (notes, annotations, review comments) had already been told the agent never took it and still offered to send it, so sending from the composer delivered it with the source never clearing, or delivered it twice. A withdrawn message sent from elsewhere now stays in the outbox as rejected, with the withdrawal as its reason, so it reads "Not sent: This message was withdrawn before the agent started it. Send it again from where you started it." with no Retry; nothing goes to the composer. That holds both for a withdrawal the host settles and for an unsent one a Stop takes out locally. --- ...session-withdrawn-message-restore.test.tsx | 68 ++++++++++++++++--- ...agent-session-withdrawn-message-restore.ts | 3 +- .../structured-agent-session-outbox-source.ts | 19 ++++++ ...ed-agent-session-outbox-stop-withdrawal.ts | 19 +++--- src/shared/structured-agent-session-outbox.ts | 4 +- 5 files changed, 95 insertions(+), 18 deletions(-) create mode 100644 src/shared/structured-agent-session-outbox-source.ts diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx index 150b520bcd32..b274d955d223 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx @@ -73,6 +73,8 @@ import { writeOutbox } from './structured-agent-session-outbox-storage' import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' +import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' +import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' import { useStructuredAgentSession } from './use-structured-agent-session' const SESSION = 'session-1' @@ -274,16 +276,53 @@ describe('a message the host withdrew at a Stop', () => { expect(readNativeChatDraftCache(PANE)).toBe('') }) - it('drops a withdrawn launch prompt where no composer shows the chat', () => { - const launch = enqueueStructuredAgentSessionLaunchPrompt(SESSION, 'launch text')! - const { result, rerender } = renderOutbox(null) + it.each([ + ['a composer shows the chat', PANE], + ['no composer shows the chat', null] + ])( + 'keeps a launch prompt a Stop withdrew while its start retried as not sent, when %s', + (_case, composer) => { + const launch = enqueueStructuredAgentSessionLaunchPrompt(SESSION, 'launch text')! + const id = launch.clientMessageId + const { result, rerender } = renderOutbox(composer) + rerender({ + submissions: [ + submission(id, { + startFailure: { + attempts: 1, + reason: 'An account switch is in progress.', + rejection: { kind: 'accountSwitchInProgress' }, + failedAt: 1, + nextAttemptAt: 15_001 + } + }) + ] + }) - expect(() => rerender({ submissions: [withdrawn(launch.clientMessageId)] })).not.toThrow() + rerender({ submissions: [withdrawn(id, { rejection: { kind: 'cancelled' } })] }) - expect(result.current.outbox).toEqual([]) - expect(readOutbox(SESSION)).toEqual([]) - expect(readNativeChatDraftCache(PANE)).toBe('') - }) + // Its source still holds it as unsent; a composer copy would send it twice. + expect(readNativeChatDraftCache(PANE)).toBe('') + expect(result.current.outbox).toEqual([ + expect.objectContaining({ + clientMessageId: id, + state: 'rejected', + lastFailure: expect.objectContaining({ rejection: { kind: 'cancelled' } }) + }) + ]) + const notice = structuredAgentSessionDeliveryNotices( + result.current.outbox, + 'Claude', + vi.fn(), + [], + [], + new Set() + ).get(agentJournalSubmissionKey(id)) + expect(notice).toEqual({ + text: 'Not sent: This message was withdrawn before the agent started it. Send it again from where you started it.' + }) + } + ) }) describe('a message a Stop took out of the outbox before the host held it', () => { @@ -328,6 +367,19 @@ describe('a message a Stop took out of the outbox before the host held it', () = expect(readNativeChatDraftCache(PANE)).toBe('hello') }) + it('keeps a launch prompt not yet sent as not sent, and gives it nothing in the composer', () => { + const launch = enqueueStructuredAgentSessionLaunchPrompt(SESSION, 'launch text')! + mocks.call.mockImplementation(() => new Promise(() => {})) + const { result } = renderOutbox() + + act(() => result.current.withdrawUnsent()) + + expect(readNativeChatDraftCache(PANE)).toBe('') + expect(result.current.outbox).toEqual([ + expect.objectContaining({ clientMessageId: launch.clientMessageId, state: 'rejected' }) + ]) + }) + it('leaves a message waiting on Retry where it is, and gives back only what it withdrew', async () => { writeOutbox(SESSION, [ { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts index 40e69565ef07..edd869192c8f 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts @@ -25,7 +25,8 @@ function restoreWithdrawnMessages( getStructuredAgentSessionOutbox(sessionId).map((entry) => entry.clientMessageId) ) for (const entry of withdrawn) { - if (!held.has(entry.clientMessageId)) { + // One sent from elsewhere goes back to its source, which still holds it, not to the composer. + if (!held.has(entry.clientMessageId) || entry.source !== undefined) { continue } const blocks = entry.body.blocks diff --git a/src/shared/structured-agent-session-outbox-source.ts b/src/shared/structured-agent-session-outbox-source.ts new file mode 100644 index 000000000000..eb8a6195bce2 --- /dev/null +++ b/src/shared/structured-agent-session-outbox-source.ts @@ -0,0 +1,19 @@ +// An outbox entry sent from outside the chat (a launch's prompt, notes sent to it): its source +// still holds it as unsent until the agent takes it, and is where it is sent again. + +import type { + StructuredAgentSessionAttemptFailure, + StructuredAgentSessionOutboxEntry +} from './structured-agent-session-outbox' + +/** Withdrawn by a Stop, it stays as not sent rather than going to the composer as a second copy. */ +export function withdrawnFromItsSource( + entry: StructuredAgentSessionOutboxEntry, + lastFailure: StructuredAgentSessionAttemptFailure = { + kind: 'rejected', + reason: null, + rejection: { kind: 'cancelled' } + } +): StructuredAgentSessionOutboxEntry { + return { ...entry, state: 'rejected', lastFailure } +} diff --git a/src/shared/structured-agent-session-outbox-stop-withdrawal.ts b/src/shared/structured-agent-session-outbox-stop-withdrawal.ts index 5606ae0c9ceb..a396ec61f22c 100644 --- a/src/shared/structured-agent-session-outbox-stop-withdrawal.ts +++ b/src/shared/structured-agent-session-outbox-stop-withdrawal.ts @@ -1,5 +1,6 @@ import type { AgentJournalSubmission } from './agent-session-journal-types' import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' +import { withdrawnFromItsSource } from './structured-agent-session-outbox-source' import { structuredAgentSessionEntryHeldForRetry } from './structured-agent-session-outbox-admission' import { handedOffQueuedMessageIds } from './structured-agent-session-draft-hand-off' @@ -62,14 +63,16 @@ export function withdrawUnsentStructuredAgentSessionOutboxEntries( inFlightClientMessageId: string | null ): StructuredAgentSessionOutboxEntry[] { const unsent = unsentStructuredAgentSessionOutboxEntry(submissions) - return entries - .filter( - (entry) => - entry.clientMessageId === inFlightClientMessageId || - !unsent(entry) || - attemptedQueueSend(entry) - ) - .map(markedOutlivingStop) + return entries.flatMap((entry) => { + if ( + entry.clientMessageId === inFlightClientMessageId || + !unsent(entry) || + attemptedQueueSend(entry) + ) { + return [markedOutlivingStop(entry)] + } + return entry.source === undefined ? [] : [withdrawnFromItsSource(entry)] + }) } /** Whether a Stop has something here to withdraw: a message that would still go out on its own. */ diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index 0f4cf857b770..3bcd1d3ed803 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -12,6 +12,7 @@ import { } from './agent-session-refusal-retry' import type { AgentSessionMutationEnvelope } from './agent-session-wire' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' +import { withdrawnFromItsSource } from './structured-agent-session-outbox-source' import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' import { parseStructuredAgentSessionOutboxQueueFields } from './structured-agent-session-outbox-delivery' @@ -229,7 +230,8 @@ export function reconcileStructuredAgentSessionOutbox( submission?.dispatchState === 'rejected' && classifyDispatchRejection(submission).category === 'withdrawn' ) { - return [] + const failure = structuredAgentSessionRejectedFailure(submission) + return entry.source === undefined ? [] : [withdrawnFromItsSource(entry, failure)] } if (submission?.dispatchState === 'pending') { if (entry.state === 'dispatching') { From bbe4b3591a19a8eeb0d24114581bf78edfc8d9d9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:31:35 -0700 Subject: [PATCH 44/84] fix(native-chat): an uninstalled agent's notice says "Install it first." beside a Retry or not "Install it, then retry." read wrong under a message that has no Retry (one its source sends again), and Q4's separate source-only wording duplicated the sentence. The words beside a Retry now say "Install it first.", as they already say "Sign in first.", wherever the message is shown, so the source-only flag and the old key go. A launch prompt refused for good when it was sent now keeps the refusal as its reason, so its notice says why instead of "Not sent: Message was not sent." --- .../agent-session-failure-words-text.ts | 2 -- ...red-agent-session-delivery-notices.test.ts | 6 ++-- ...ructured-agent-session-delivery-notices.ts | 4 +-- .../src/i18n/en-runtime-required.json | 1 - src/renderer/src/i18n/locales/en.json | 1 - src/renderer/src/i18n/locales/es.json | 1 - src/renderer/src/i18n/locales/fr.json | 1 - src/renderer/src/i18n/locales/ja.json | 1 - src/renderer/src/i18n/locales/ko.json | 1 - src/renderer/src/i18n/locales/zh.json | 1 - ...ctured-agent-session-launch-prompt.test.ts | 34 +++++++++++++++++++ .../structured-agent-session-launch-prompt.ts | 11 +++--- src/shared/agent-session-failure-copy.ts | 1 - src/shared/agent-session-failure-words.ts | 13 +++---- 14 files changed, 50 insertions(+), 28 deletions(-) diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 9da6c85c5fc8..dd91a4aa3a52 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -64,8 +64,6 @@ const PIECES: Record translate('components.native-chat.failureWords.installFirst', COPY.installFirst), - installThenRetry: () => - translate('components.native-chat.failureWords.installThenRetry', COPY.installThenRetry), installThenRunCommand: (values) => translate( 'components.native-chat.failureWords.installThenRunCommand', diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index 827160d94c11..db57fb43429f 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -595,8 +595,10 @@ describe('the notice on a message sent from elsewhere that was not sent', () => expect(launch?.text).toBe( "Not sent: Claude isn't installed. Install it first. Send it again from where you started it." ) - // A message typed in the chat keeps its own Retry. - expect(notices.get(agentJournalSubmissionKey('typed'))?.onRetry).toBeTypeOf('function') + // A message typed in the chat keeps its own Retry, and the same step to take first. + const typed = notices.get(agentJournalSubmissionKey('typed')) + expect(typed?.onRetry).toBeTypeOf('function') + expect(typed?.text).toBe("Claude isn't installed. Install it first.") }) it('offers no Retry on notes the host refused and the chat holds, since their source sends them again', () => { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts index 94bced4d3f3d..a90dde5407c8 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts @@ -233,9 +233,7 @@ export function structuredAgentSessionDeliveryNotices( const retryControl = stalledFrom === -1 || index <= stalledFrom const text = deliveryNoticeText( entry, - sentFromSource(entry) - ? { agentName, retryControl: true, sourceRetries: true } - : { agentName, retryControl }, + sentFromSource(entry) ? { agentName, retryControl: true } : { agentName, retryControl }, rejected.get(entry.clientMessageId), startFailures, failedHere diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 5548d51fed3f..207bc5c36787 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2755,7 +2755,6 @@ "hostRestarted": "Orca restarted before this message was sent.", "hostStopped": "{{agent}} never finished starting, so Orca stopped it.", "installFirst": "Install it first.", - "installThenRetry": "Install it, then retry.", "installThenRunCommand": "Install it, then run /{{command}} again.", "installThenSend": "Install it, then send your message again.", "managedAccountEnvOverride": "This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5efa042ea6a8..e8ec307c11d5 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17501,7 +17501,6 @@ "signInThenSend": "Sign in, then send your message again.", "providerMissing": "{{agent}} isn't installed.", "installFirst": "Install it first.", - "installThenRetry": "Install it, then retry.", "installThenRunCommand": "Install it, then run /{{command}} again.", "installThenSend": "Install it, then send your message again.", "historyTooLarge": "This conversation's history is too large to restore here.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 197c6a229d25..fe7e442cd335 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14942,7 +14942,6 @@ "signInThenSend": "Inicie sesión y vuelva a enviar su mensaje.", "providerMissing": "{{agent}} no está instalado.", "installFirst": "Instálelo primero.", - "installThenRetry": "Instálelo y vuelva a intentarlo.", "installThenRunCommand": "Instálelo y vuelva a ejecutar /{{command}}.", "installThenSend": "Instálelo y vuelva a enviar su mensaje.", "historyTooLarge": "El historial de esta conversación es demasiado grande para restaurarlo aquí.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 997450ed2ffe..03f71f2bbfd1 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17408,7 +17408,6 @@ "signInThenSend": "Connectez-vous, puis renvoyez votre message.", "providerMissing": "{{agent}} n'est pas installé.", "installFirst": "Installez-le d'abord.", - "installThenRetry": "Installez-le, puis réessayez.", "installThenRunCommand": "Installez-le, puis relancez /{{command}}.", "installThenSend": "Installez-le, puis renvoyez votre message.", "historyTooLarge": "L'historique de cette conversation est trop volumineux pour être restauré ici.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index a9b8111d8c6e..69cbf0ca9dfe 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17344,7 +17344,6 @@ "signInThenSend": "サインインしてから、メッセージをもう一度送信してください。", "providerMissing": "{{agent}} はインストールされていません。", "installFirst": "先にインストールしてください。", - "installThenRetry": "インストールしてから、もう一度お試しください。", "installThenRunCommand": "インストールしてから、/{{command}} をもう一度実行してください。", "installThenSend": "インストールしてから、メッセージをもう一度送信してください。", "historyTooLarge": "この会話の履歴は大きすぎるため、ここでは復元できません。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 7967b1c6bf0d..b62fcca9a1f9 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17344,7 +17344,6 @@ "signInThenSend": "로그인한 다음 메시지를 다시 보내세요.", "providerMissing": "{{agent}}이(가) 설치되어 있지 않습니다.", "installFirst": "먼저 설치하세요.", - "installThenRetry": "설치한 다음 다시 시도하세요.", "installThenRunCommand": "설치한 다음 /{{command}}을(를) 다시 실행하세요.", "installThenSend": "설치한 다음 메시지를 다시 보내세요.", "historyTooLarge": "이 대화의 기록이 너무 커서 여기에서 복원할 수 없습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 6903ac261e87..371fca965388 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17309,7 +17309,6 @@ "signInThenSend": "请登录,然后重新发送您的消息。", "providerMissing": "{{agent}} 未安装。", "installFirst": "请先安装。", - "installThenRetry": "请安装,然后重试。", "installThenRunCommand": "请安装,然后重新运行 /{{command}}。", "installThenSend": "请安装,然后重新发送您的消息。", "historyTooLarge": "此对话的历史记录过大,无法在此恢复。", diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts index 4c7f891fbfc3..8dbb894f0949 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts @@ -3,8 +3,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { enqueueStructuredAgentSessionLaunchPrompt, + getStructuredAgentSessionOutbox, mutateStructuredAgentSessionLaunchPrompt } from '@/components/native-chat/structured-agent-session-outbox-storage' +import { structuredAgentSessionDeliveryNotices } from '@/components/native-chat/structured-agent-session-delivery-notices' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' const mocks = vi.hoisted(() => ({ call: vi.fn(), subscribe: vi.fn() })) @@ -164,6 +167,37 @@ describe('settleStructuredAgentLaunchPrompt', () => { ) }) + it('keeps why a launch prompt was refused for good, so its notice says it', async () => { + const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'review this') + mocks.call.mockResolvedValue({ + ok: false, + refusal: { code: 'agent_session_operation_invalid', message: 'invalid' } + }) + + await settleStructuredAgentLaunchPrompt({ + launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), + options: { prompt: 'review this' }, + stagedEntry + }) + + const [refused] = getStructuredAgentSessionOutbox('session-1') + expect(refused).toMatchObject({ + state: 'rejected', + lastFailure: { kind: 'refused', code: 'agent_session_operation_invalid' } + }) + const notice = structuredAgentSessionDeliveryNotices( + [refused!], + 'Claude', + vi.fn(), + [], + [], + new Set() + ) + expect(notice.get(agentJournalSubmissionKey(refused!.clientMessageId))?.text).not.toContain( + 'Message was not sent.' + ) + }) + // Whatever the start's failure (signed out, not installed, the agent exiting as it started), it // ends as the message's rejection; nothing that writes on delivery may run. }) diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index 6e5804d19e90..4402d8c216df 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -127,14 +127,17 @@ async function dispatchStructuredLaunchPrompt( structuredAgentSessionSendRequest(entry, receipt.fence) ) if (!result.ok) { - mutateEntry(entry, (current) => - requeueStructuredAgentSessionSendRefusal( + const failure = agentSessionRefusalFailure(result.refusal) + mutateEntry(entry, (current) => { + const requeued = requeueStructuredAgentSessionSendRefusal( current, - agentSessionRefusalFailure(result.refusal), + failure, () => createStructuredAgentSessionOperationId(createBrowserUuid), entry.lastAttemptAt !== null ) - ) + // Refused for good, the chat shows it as not sent, so it says why. + return requeued.state === 'rejected' ? { ...requeued, lastFailure: failure } : requeued + }) return false } if ('queued' in result.value) { diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index fbe783b15bce..45ca02aa65da 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -25,7 +25,6 @@ export const AGENT_SESSION_FAILURE_COPY = { signInThenSend: 'Sign in, then send your message again.', providerMissing: "{{agent}} isn't installed.", installFirst: 'Install it first.', - installThenRetry: 'Install it, then retry.', installThenRunCommand: 'Install it, then run /{{command}} again.', installThenSend: 'Install it, then send your message again.', historyTooLarge: "This conversation's history is too large to restore here.", diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 5c1a74360239..145c60fff9d3 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -68,9 +68,6 @@ export type AgentSessionFailureWordsContext = { /** Orca starts it again on its own, so the words leave out even when to try again. Implies * `retryControl`. */ orcaRetries?: boolean - /** Where the message was sent from sends it again, so the words name only the person's own step - * first. Implies `retryControl`. */ - sourceRetries?: boolean } /** Person-facing provider text is quoted, but bounded so the sentence stays one. */ @@ -184,13 +181,11 @@ const FAILURE_SENTENCES = { providerMissing: (context, _fact, _surface, say) => joinSentences([ say('providerMissing', agent(say, context)), - context.sourceRetries + context.retryControl ? say('installFirst') - : context.retryControl - ? say('installThenRetry') - : context.command - ? say('installThenRunCommand', { command: context.command }) - : say('installThenSend') + : context.command + ? say('installThenRunCommand', { command: context.command }) + : say('installThenSend') ]), historyTooLarge: (_context, _fact, _surface, say) => joinSentences([say('historyTooLarge'), say('startNewChat')]), From edac8df8747953dc9f8f8f32c56d8657b4f14a09 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:12:41 -0700 Subject: [PATCH 45/84] test(native-chat): line up #24340's tests with a chat created at rest and its startRetry After the merge: a stop-event rig wrote at the at-rest fence 1 under an agent started at 2; the preamble and readiness fakes now tell the in-budget verdict wait from the watch past it; the create tab-publication stub answers create; and a Claude start whose process could not be proven gone is rejected at once, as #24340 now decides, not held for another try. --- .../structured-agent-session-stop-event.test.ts | 6 +++++- .../runtime/claude-structured-session-integration.test.ts | 7 ++----- .../orchestration-structured-worker-session.test.ts | 6 ++++-- ...structured-agent-session-create-tab-publication.test.ts | 6 +++--- 4 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts index 1ceff1b63a44..3d63aa12d87a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event.test.ts @@ -51,7 +51,11 @@ async function turnRow(turnId: string, state: 'running' | 'interrupted') { return journal().appendItem( { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 999 }, { kind: 'turn', turnId, state, startedAt: 1 }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + // The rig's chat was created at rest (fence 1) and its agent started at the next. + { + fence: rig.store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } ) } diff --git a/src/main/runtime/claude-structured-session-integration.test.ts b/src/main/runtime/claude-structured-session-integration.test.ts index 997984ce3093..d35958435928 100644 --- a/src/main/runtime/claude-structured-session-integration.test.ts +++ b/src/main/runtime/claude-structured-session-integration.test.ts @@ -513,12 +513,9 @@ describe('a structured Claude session over agentSession.*', () => { await ok<{ fence: number }>('agentSession.create', createIntentParams()) const sent = await send('hello') - // Unproven, so the message waits for another start rather than ending. + // A start whose process could not be proven gone books no other try: the message ends. await vi.waitFor(async () => - expect(await submissionOf(sent.clientMessageId)).toMatchObject({ - dispatchState: 'pending', - startRetry: { attempts: 1, rejection: { kind: 'startFailed' } } - }) + expect(await submissionOf(sent.clientMessageId)).toMatchObject({ dispatchState: 'rejected' }) ) expect(claude.connections).toHaveLength(1) claude.setSelfExit(null) diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts index 7c0748f794a8..0c8858de77b7 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-session.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' import { getAppEnvironment } from '../../../../shared/app-environment' import { DISPATCH_REJECTED_WRITE_FAILED } from '../../../../shared/structured-agent-session-dispatch-rejection' +import { ORCHESTRATION_READINESS_TIMEOUT_MS } from '../../../../shared/orchestration-timing-budgets' const hostRef: { current: unknown } = { current: null } const createSpy = vi.fn() @@ -375,8 +376,9 @@ describe('structured worker dispatch preamble', () => { const verdicts: unknown[] = [] const heldThen = (verdict: Settled): PreambleHost => ({ ...hostWithSubmission({ dispatchState: 'pending', reason: null }), + // Only the watch past the start's wait hears the verdict; within it the agent never took it. waitForSendSettlement: async (_session, _id, options) => - options?.until === 'verdict' + options?.until === 'verdict' && (options.budgetMs ?? 0) > ORCHESTRATION_READINESS_TIMEOUT_MS ? { cursor: { epoch: 'epoch-1', sequence: 3 }, value: { clientMessageId: 'c1', submission: submissionOf(verdict) } @@ -399,7 +401,7 @@ describe('structured worker dispatch preamble', () => { ) } - expect(verdicts).toEqual(['pending', 'pending']) + expect(verdicts).toEqual([{ state: 'pending' }, { state: 'pending' }]) await vi.waitFor(() => expect(heard).toHaveBeenCalledOnce()) expect(heard).toHaveBeenCalledWith( 'The dispatch preamble was not delivered: Codex never finished starting, so Orca stopped it.' diff --git a/src/main/runtime/rpc/methods/structured-agent-session-create-tab-publication.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-create-tab-publication.test.ts index cf708f374916..627e398ac265 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-create-tab-publication.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-create-tab-publication.test.ts @@ -9,9 +9,9 @@ import { commitStructuredAgentSessionCreate } from './structured-agent-session-c it('logs a created chat whose tab could not be published, and answers it unconfirmed', async () => { const log = recordingStructuredAgentSessionLogger() const failure = new Error('tab write failed') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements the only members the commit reaches past attach. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements the only members the commit reaches past create. const host = { - attach: vi.fn(async () => ({ ok: true, value: { sessionId: 'session-1' } })), + create: vi.fn(async () => ({ ok: true, value: { sessionId: 'session-1' } })), getSessionTabId: vi.fn(() => null), deps: { logger: log.logger } } as unknown as StructuredAgentSessionHost @@ -27,7 +27,7 @@ it('logs a created chat whose tab could not be published, and answers it unconfi caller: { callerKey: 'client-1' }, prepared: { host, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stubbed attach reads none of it. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stubbed create reads none of it. attachParams: {} as AgentSessionAttachParams, tab: { workspaceId: 'workspace-1', agent: 'codex' } }, From 6ed5e6b80f8cd20b43ccbde9acd8b5b74a991f31 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:16:51 -0700 Subject: [PATCH 46/84] revert(native-chat): sends into an existing chat keep the chat's Retry, and count as sent on acceptance again Reverts the behaviour of 8e0169301df, fba2e4c971b and 58ad6d364c6 (Q4) by ruling: it marked notes, annotations and picker sends into ANY existing chat as owned by their source, which took the chat's own Retry away from them, beyond this PR's scope. With #24340's Retry now re-arming the same message, those sends keep the chat's normal Retry, and sendMessageToAgent reports them sent once the chat queues them, as before. The source-owned outbox value, the outbox commit listener, the generalized waiter, the not-taken status and Q4's tests go; the launch prompt's waiter is the handover module again. Kept from that work, for new-chat launch prompts only (the Q3 interim): a launch prompt refused for good stays in the chat as not sent under its own id, which is what lets its notice give the refusal as the reason; a held launch prompt keeps its Retry, as Q3 had it. A Stop on a launch prompt still keeps it as not sent instead of pasting it into the composer (R3N2), now scoped to launch entries by name. --- .../ReviewNotesSendMenuContent.test.tsx | 78 ++--- ...red-agent-session-delivery-notices.test.ts | 17 +- ...ructured-agent-session-delivery-notices.ts | 11 +- ...structured-agent-session-outbox-storage.ts | 29 +- ...agent-session-withdrawn-message-restore.ts | 4 +- .../lib/active-agent-note-send-diagnostics.ts | 2 - .../src/lib/active-agent-note-send-result.ts | 6 - ...t-message-send-structured-delivery.test.ts | 279 ------------------ src/renderer/src/lib/agent-message-send.ts | 12 +- ...ed-agent-session-launch-prompt-handover.ts | 92 ++++++ ...gent-session-launch-prompt-test-support.ts | 14 +- ...ctured-agent-session-launch-prompt.test.ts | 6 +- .../structured-agent-session-launch-prompt.ts | 10 +- ...red-agent-session-sourced-message-taken.ts | 128 -------- .../store/slices/ui-agent-send-target.test.ts | 12 +- .../agent-session-refusal-retry.test.ts | 21 -- .../structured-agent-session-outbox-source.ts | 4 +- ...ed-agent-session-outbox-stop-withdrawal.ts | 2 +- src/shared/structured-agent-session-outbox.ts | 15 +- 19 files changed, 164 insertions(+), 578 deletions(-) delete mode 100644 src/renderer/src/lib/agent-message-send-structured-delivery.test.ts create mode 100644 src/renderer/src/lib/structured-agent-session-launch-prompt-handover.ts delete mode 100644 src/renderer/src/lib/structured-agent-session-sourced-message-taken.ts diff --git a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx index 30249f1f26ed..7c1961f7d7cf 100644 --- a/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx +++ b/src/renderer/src/components/editor/ReviewNotesSendMenuContent.test.tsx @@ -682,55 +682,37 @@ describe('ReviewNotesSendMenuContent', () => { }) }) - it.each([ - ['a terminal not ready', { kind: 'terminal', tabId: TAB_A, leafId: LEAF_A }, 'not-ready'], - // A chat's agent starts on the notes: they wait for it, and stay unsent when it never takes them. - [ - 'a chat whose agent did not take them', - { kind: 'structured-session', sessionId: 'c1' }, - 'not-taken' + it('keeps selected-target note failures undelivered and uses selected wording', async () => { + const statusPaneKey = makePaneKey(TAB_A, LEAF_A) + const onPromptDelivered = vi.fn() + harness.sendMessageToAgent.mockResolvedValue({ status: 'not-ready' }) + setStore({ + tabsByWorktree: { 'wt-1': [tab(TAB_A, { title: 'Terminal 1' })] }, + terminalLayoutsByTabId: { [TAB_A]: leafLayout(LEAF_A, 'pty-a') } + }) + harness.noteTargets = [ + { + paneKey: statusPaneKey, + tabId: TAB_A, + messageTarget: { kind: 'terminal', tabId: TAB_A, leafId: LEAF_A }, + agentType: 'claude', + tabTitle: 'Terminal 1', + status: 'eligible' + } ] - ] as const)( - 'keeps notes to %s undelivered, in selected wording', - async (_case, target, status) => { - const onPromptDelivered = vi.fn() - let answer!: (result: { status: typeof status }) => void - harness.sendMessageToAgent.mockReturnValue(new Promise((resolve) => (answer = resolve))) - const successes = () => - toast.getToasts().filter((entry) => 'type' in entry && entry.type === 'success').length - const before = successes() - setStore({ - tabsByWorktree: { 'wt-1': [tab(TAB_A, { title: 'Terminal 1' })] }, - terminalLayoutsByTabId: { [TAB_A]: leafLayout(LEAF_A, 'pty-a') } - }) - harness.noteTargets = [ - { - paneKey: makePaneKey(TAB_A, LEAF_A), - tabId: TAB_A, - messageTarget: target, - agentType: 'claude', - tabTitle: 'Terminal 1', - status: 'eligible' - } - ] - - const tree = render({ onPromptDelivered }) - ;(findByType(tree, 'DropdownMenuItem').props.onSelect as () => void)() - await flushMicrotasks() - expect(onPromptDelivered).not.toHaveBeenCalled() - answer({ status }) - await flushMicrotasks() - - expect(onPromptDelivered).not.toHaveBeenCalled() - expect(harness.track).not.toHaveBeenCalled() - expect(successes()).toBe(before) - // Why: a failure must replace the 'Sending notes...' loading toast, not keep its spinner. - const settled = toast - .getToasts() - .find((entry) => 'title' in entry && entry.title === `selected:${status}`) - expect(settled && 'type' in settled ? settled.type : undefined).toBe('info') - } - ) + + const tree = render({ onPromptDelivered }) + ;(findByType(tree, 'DropdownMenuItem').props.onSelect as () => void)() + await flushMicrotasks() + + expect(onPromptDelivered).not.toHaveBeenCalled() + expect(harness.track).not.toHaveBeenCalled() + // Why: a failure must replace the 'Sending notes...' loading toast, not keep its spinner. + const settled = toast + .getToasts() + .find((entry) => 'title' in entry && entry.title === 'selected:not-ready') + expect(settled && 'type' in settled ? settled.type : undefined).toBe('info') + }) it('keeps selected-target thrown send errors undelivered', async () => { const statusPaneKey = makePaneKey(TAB_A, LEAF_A) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index 5cf8310a2baa..08696c06123a 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -569,7 +569,7 @@ describe('the notice on each message that did not go through', () => { // A launch prompt belongs to where it was sent from (notes, review comments, a fix action), which // still holds it as unsent once the host rejects it for good: that is where it goes again. -describe('the notice on a message sent from elsewhere that was not sent', () => { +describe('the notice on a launch prompt the host rejected for good', () => { const notInstalled = { kind: 'rejected' as const, reason: "Claude isn't installed.", @@ -601,12 +601,14 @@ describe('the notice on a message sent from elsewhere that was not sent', () => expect(typed?.text).toBe("Claude isn't installed. Install it first.") }) - it('offers no Retry on notes the host refused and the chat holds, since their source sends them again', () => { - const refused = { kind: 'refused', code: 'agent_session_operation_capacity' } as const + it('keeps Retry on a launch prompt only held, not rejected', () => { const notices = structuredAgentSessionDeliveryNotices( [ - entry('notes', { source: 'surface', state: 'queued', lastFailure: refused }), - entry('typed', { state: 'queued', lastFailure: refused }) + entry('launch', { + source: 'launch', + state: 'queued', + lastFailure: { kind: 'refused', code: 'agent_session_operation_capacity' } + }) ], 'Claude', vi.fn(), @@ -615,10 +617,7 @@ describe('the notice on a message sent from elsewhere that was not sent', () => NOT_FAILED_HERE ) - const notes = notices.get(agentJournalSubmissionKey('notes')) - expect(notes?.onRetry).toBeUndefined() - expect(notes?.text).toMatch(/Send it again from where you started it\.$/) - expect(notices.get(agentJournalSubmissionKey('typed'))?.onRetry).toBeTypeOf('function') + expect(notices.get(agentJournalSubmissionKey('launch'))?.onRetry).toBeTypeOf('function') }) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts index 19c48936e105..4d3ecc52f802 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts @@ -181,14 +181,11 @@ function deliveryNoticeText( ) } -/** A message sent from elsewhere (a launch's prompt, review notes) and not sent belongs to where it - * came from: that source still holds it as unsent and is where it goes again, so a Retry here - * would deliver it while the source never learns it went. */ +/** A launch prompt the host rejected for good belongs to where it was sent from (notes, review + * comments, a fix action): that source still holds it as unsent and is where it goes again, so a + * Retry here would deliver it while the source never learns it went. */ function sentFromSource(entry: StructuredAgentSessionOutboxEntry): boolean { - return ( - entry.source !== undefined && - (entry.state === 'rejected' || structuredAgentSessionEntryHeldForRetry(entry)) - ) + return entry.source === 'launch' && entry.state === 'rejected' } function sourcedMessageNotSentText(reason: string): string { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts index 27e2e05b0080..a28749eed20a 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts @@ -2,8 +2,7 @@ import { createStructuredAgentSessionOutboxEntry, parseStructuredAgentSessionOutboxEntry, type StructuredAgentSessionAttachment, - type StructuredAgentSessionOutboxEntry, - type StructuredAgentSessionOutboxSource + type StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation' import { createBrowserUuid } from '@/lib/browser-uuid' @@ -224,27 +223,6 @@ export function subscribeToStructuredAgentSessionOutbox( } } -const committedListeners = new Map< - string, - Set<(entries: readonly StructuredAgentSessionOutboxEntry[]) => void> ->() - -/** Every outbox `entries` becomes, whether or not a chat holds it open. */ -export function subscribeToStructuredAgentSessionOutboxCommits( - sessionId: string, - listener: (entries: readonly StructuredAgentSessionOutboxEntry[]) => void -): () => void { - const listeners = committedListeners.get(sessionId) ?? new Set() - listeners.add(listener) - committedListeners.set(sessionId, listeners) - return () => { - listeners.delete(listener) - if (listeners.size === 0 && committedListeners.get(sessionId) === listeners) { - committedListeners.delete(sessionId) - } - } -} - /** Makes `entries` the session's outbox and saves it. With `onlyIfSaved`, a failed save leaves * the outbox as it was; otherwise the open chat still shows the change. */ export function commitStructuredAgentSessionOutbox( @@ -263,9 +241,6 @@ export function commitStructuredAgentSessionOutbox( listener() } } - for (const listener of committedListeners.get(sessionId) ?? []) { - listener(entries) - } return saved } @@ -275,7 +250,7 @@ export function appendStructuredAgentSessionOutboxMessage( sessionId: string, text: string, attachments: readonly StructuredAgentSessionAttachment[] = [], - source?: StructuredAgentSessionOutboxSource + source?: 'launch' ): StructuredAgentSessionOutboxEntry | null { const entry = { ...createStructuredAgentSessionOutboxEntry({ diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts index edd869192c8f..94337a62586e 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts @@ -25,8 +25,8 @@ function restoreWithdrawnMessages( getStructuredAgentSessionOutbox(sessionId).map((entry) => entry.clientMessageId) ) for (const entry of withdrawn) { - // One sent from elsewhere goes back to its source, which still holds it, not to the composer. - if (!held.has(entry.clientMessageId) || entry.source !== undefined) { + // A launch prompt goes back to its source, which still holds it, not to the composer. + if (!held.has(entry.clientMessageId) || entry.source === 'launch') { continue } const blocks = entry.body.blocks diff --git a/src/renderer/src/lib/active-agent-note-send-diagnostics.ts b/src/renderer/src/lib/active-agent-note-send-diagnostics.ts index a7ef472e778c..155dec17e067 100644 --- a/src/renderer/src/lib/active-agent-note-send-diagnostics.ts +++ b/src/renderer/src/lib/active-agent-note-send-diagnostics.ts @@ -78,7 +78,5 @@ function codeForStatus( return 'terminal-send-refused' case 'partial-submit-failed': return 'submit-send-error' - case 'not-taken': - return 'session-message-not-taken' } } diff --git a/src/renderer/src/lib/active-agent-note-send-result.ts b/src/renderer/src/lib/active-agent-note-send-result.ts index 33814fadfb4d..30bb4515a5e1 100644 --- a/src/renderer/src/lib/active-agent-note-send-result.ts +++ b/src/renderer/src/lib/active-agent-note-send-result.ts @@ -8,8 +8,6 @@ export type ActiveAgentNotesSendStatus = | 'not-ready' | 'not-writable' | 'partial-submit-failed' - /** The chat's agent never took the message: its start failed, or the chat closed first. */ - | 'not-taken' export type ActiveAgentNotesSendFailureCode = | 'empty' @@ -36,7 +34,6 @@ export type ActiveAgentNotesSendFailureCode = | 'runtime-unverifiable' | 'runtime-timeout' | 'session-outbox-unsaved' - | 'session-message-not-taken' export type ActiveAgentNotesSendResult = { status: ActiveAgentNotesSendStatus @@ -75,9 +72,6 @@ export function activeAgentNotesSendFailureMessage( case 'not-writable': message = `The ${target} agent did not accept the notes.` break - case 'not-taken': - message = `The ${target} agent did not take the notes; its chat says why.` - break case 'partial-submit-failed': message = options.explicitTarget ? 'The notes may already be pasted in the selected terminal, but Orca could not submit them.' diff --git a/src/renderer/src/lib/agent-message-send-structured-delivery.test.ts b/src/renderer/src/lib/agent-message-send-structured-delivery.test.ts deleted file mode 100644 index e1d40d4524b9..000000000000 --- a/src/renderer/src/lib/agent-message-send-structured-delivery.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -// @vitest-environment happy-dom -// Notes or annotations sent to an existing chat at rest: its agent starts on that message, which -// may fail. The source clears what it sent only once the agent took it, and a message not taken -// stays in the chat without a Retry there, since the source still holds it. - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { StoreApi } from 'zustand/vanilla' -import { structuredAgentSessionPaneKey } from '../../../shared/structured-agent-session-projection' -import { - reconcileStructuredAgentSessionOutbox, - stageStructuredAgentSessionOutboxEntryForSend -} from '../../../shared/structured-agent-session-outbox' -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import { - commitStructuredAgentSessionOutbox, - getStructuredAgentSessionOutbox, - mutateStructuredAgentSessionLaunchPrompt -} from '@/components/native-chat/structured-agent-session-outbox-storage' -import { structuredAgentSessionDeliveryNotices } from '@/components/native-chat/structured-agent-session-delivery-notices' -import type { AppState } from '@/store/types' -import { createUIStore } from '@/store/slices/ui-slice-test-harness' -import { sendMessageToAgent } from './agent-message-send' -import { - FIRST_START_FAILS, - firstMessageStream, - play, - type FirstMessageStream -} from './structured-agent-session-launch-prompt-test-support' - -const client = vi.hoisted(() => ({ call: vi.fn(), subscribe: vi.fn() })) -vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: client.call, - subscribeStructuredAgentSession: client.subscribe -})) - -vi.mock('./structured-agent-session-launch', () => ({ - relaunchFailedStructuredAgentSessionForMessage: vi.fn() -})) - -const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), message: vi.fn() })) -vi.mock('sonner', () => ({ toast })) -vi.mock('@/lib/telemetry', () => ({ track: vi.fn() })) - -const WORKTREE = 'wt-1' -let session = 0 -let sessionId = '' - -/** The one message the code under test queued on the chat. */ -function sentEntry() { - const [entry] = getStructuredAgentSessionOutbox(sessionId) - return entry -} - -/** The open chat's drain putting the queued message on the wire, as it does for any send. */ -function chatSendsIt(): void { - mutateStructuredAgentSessionLaunchPrompt(sessionId, sentEntry().clientMessageId, (entry) => - stageStructuredAgentSessionOutboxEntryForSend(entry, Date.now()) - ) -} - -/** The open chat folding the journal's answer into its outbox. */ -function chatReconciles(submission: AgentJournalSubmission): void { - commitStructuredAgentSessionOutbox( - sessionId, - reconcileStructuredAgentSessionOutbox(getStructuredAgentSessionOutbox(sessionId), [submission]) - ) -} - -function rejectedSubmission(): AgentJournalSubmission { - return { - clientMessageId: sentEntry().clientMessageId, - fence: 1, - payloadFingerprint: 'fingerprint', - dispatchState: 'rejected', - providerItemId: null, - reason: "Claude isn't installed.", - rejection: { kind: 'providerMissing' }, - submittedAt: 1, - resolvedAt: 2 - } -} - -function settledState(promise: Promise): () => T | 'unsettled' { - let state: T | 'unsettled' = 'unsettled' - void promise.then((value) => { - state = value - }) - return () => state -} - -async function sendNotes(): Promise<{ - result: Promise>> - stream: FirstMessageStream -}> { - const opened = firstMessageStream(client, () => sentEntry().clientMessageId) - const result = sendMessageToAgent({ - worktreeId: WORKTREE, - prompt: 'Review these notes', - target: { kind: 'structured-session', sessionId } - }) - const stream = await opened - chatSendsIt() - return { result, stream } -} - -beforeEach(() => { - session += 1 - sessionId = `claude-${session}` - client.call.mockReset() - client.subscribe.mockReset() - toast.success.mockReset() - toast.error.mockReset() -}) - -afterEach(() => { - localStorage.clear() -}) - -describe('notes sent to a chat whose agent starts on them', () => { - it('count as sent only once a retried start takes them', async () => { - const { result, stream } = await sendNotes() - const sent = settledState(result) - - play(stream, FIRST_START_FAILS.retriedThenTaken.slice(0, -1)) - await Promise.resolve() - expect(sent()).toBe('unsettled') - - play(stream, FIRST_START_FAILS.retriedThenTaken.slice(-1)) - await expect(result).resolves.toEqual({ status: 'sent' }) - expect(stream.open()).toBe(false) - }) - - it('wait through a lost answer that a late echo proves taken', async () => { - const { result, stream } = await sendNotes() - play(stream, FIRST_START_FAILS.unknownThenTaken) - await expect(result).resolves.toEqual({ status: 'sent' }) - }) - - it.each([ - ['rejected after its tries', FIRST_START_FAILS.rejectedAfterTries], - ['withdrawn when the chat closes mid-wait', FIRST_START_FAILS.chatClosed] - ])('are not sent when %s, and stay in the chat as its source', async (_case, changes) => { - const { result, stream } = await sendNotes() - play(stream, changes) - - await expect(result).resolves.toEqual({ - status: 'not-taken', - code: 'session-message-not-taken' - }) - expect(sentEntry()).toMatchObject({ source: 'surface' }) - }) - - it('leave the chat no Retry once rejected, and say where to send them again', async () => { - const { result } = await sendNotes() - chatReconciles(rejectedSubmission()) - - await expect(result).resolves.toMatchObject({ status: 'not-taken' }) - const notice = structuredAgentSessionDeliveryNotices( - getStructuredAgentSessionOutbox(sessionId), - 'Claude', - vi.fn(), - [rejectedSubmission()], - [], - new Set() - ).get(agentJournalSubmissionKey(sentEntry().clientMessageId)) - expect(notice?.onRetry).toBeUndefined() - expect(notice?.text).toBe( - "Not sent: Claude isn't installed. Install it first. Send it again from where you started it." - ) - }) - - it('are not sent when the host refuses the send before recording it', async () => { - const { result } = await sendNotes() - mutateStructuredAgentSessionLaunchPrompt(sessionId, sentEntry().clientMessageId, (entry) => ({ - ...entry, - state: 'queued', - lastFailure: { kind: 'refused', code: 'agent_session_operation_capacity' } - })) - - await expect(result).resolves.toMatchObject({ status: 'not-taken' }) - }) -}) - -// The picker lists a chat once it has a status row; its agent may since have stopped, and starts -// again on the next message the same way. -describe('annotations sent from the agent picker to a chat whose agent starts on them', () => { - const chatTabId = 'tab-chat' - - function pickerStore(): StoreApi { - const store = createUIStore() - const now = Date.now() - const paneKey = structuredAgentSessionPaneKey(chatTabId, sessionId) - store.setState({ - agentStatusByPaneKey: { - [paneKey]: { - state: 'done', - prompt: 'previous', - updatedAt: now, - stateStartedAt: now, - agentType: 'claude', - paneKey, - stateHistory: [] - } - }, - tabsByWorktree: {}, - terminalLayoutsByTabId: {}, - ptyIdsByTabId: {}, - unifiedTabsByWorktree: { - [WORKTREE]: [ - { - id: chatTabId, - entityId: sessionId, - groupId: 'group-1', - worktreeId: WORKTREE, - contentType: 'agent-session', - agentSessionAgent: 'claude', - label: 'Claude Chat', - customLabel: null, - color: null, - sortOrder: 0, - createdAt: Date.now() - } - ] - } - }) - return store - } - - async function sendFromPicker(onPromptDelivered: () => void) { - const store = pickerStore() - store.getState().openAgentSendPopoverTargetMode({ - id: 'send-1', - worktreeId: WORKTREE, - source: 'browser-annotations', - prompt: 'Fix these annotations', - label: 'Browser annotations', - launchSource: 'notes_send', - onPromptDelivered - }) - const opened = firstMessageStream(client, () => sentEntry().clientMessageId) - const sent = store - .getState() - .sendPromptToSidebarAgentTarget(structuredAgentSessionPaneKey(chatTabId, sessionId)) - return { sent, stream: await opened } - } - - it('are cleared once, after the retried start takes them', async () => { - const onPromptDelivered = vi.fn() - const { sent, stream } = await sendFromPicker(onPromptDelivered) - - play(stream, FIRST_START_FAILS.retriedThenTaken.slice(0, -1)) - await Promise.resolve() - expect(onPromptDelivered).not.toHaveBeenCalled() - expect(toast.success).not.toHaveBeenCalled() - - play(stream, FIRST_START_FAILS.retriedThenTaken.slice(-1)) - await expect(sent).resolves.toBe(true) - expect(onPromptDelivered).toHaveBeenCalledTimes(1) - expect(toast.success).toHaveBeenCalledWith('Sent to Claude') - }) - - it.each([ - ['rejected after its tries', FIRST_START_FAILS.rejectedAfterTries], - ['withdrawn when the chat closes mid-wait', FIRST_START_FAILS.chatClosed] - ])('are kept, with a failure notice, when %s', async (_case, changes) => { - const onPromptDelivered = vi.fn() - const { sent, stream } = await sendFromPicker(onPromptDelivered) - play(stream, changes) - - await expect(sent).resolves.toBe(false) - expect(onPromptDelivered).not.toHaveBeenCalled() - expect(toast.success).not.toHaveBeenCalled() - expect(toast.error).toHaveBeenCalledWith("Couldn't send to Claude", { - description: - 'The selected agent did not take the notes; its chat says why. (session-message-not-taken)' - }) - }) -}) diff --git a/src/renderer/src/lib/agent-message-send.ts b/src/renderer/src/lib/agent-message-send.ts index 5cdd467f40d1..03f92bc22f16 100644 --- a/src/renderer/src/lib/agent-message-send.ts +++ b/src/renderer/src/lib/agent-message-send.ts @@ -3,7 +3,6 @@ import type { AgentMessageTarget } from './agent-message-target' import { sendNotesToActiveAgentSession } from './active-agent-note-send' import { relaunchFailedStructuredAgentSessionForMessage } from './structured-agent-session-launch' import { appendStructuredAgentSessionOutboxMessage } from '@/components/native-chat/structured-agent-session-outbox-storage' -import { awaitStructuredSourcedMessageTaken } from './structured-agent-session-sourced-message-taken' export type { AgentMessageTarget } from './agent-message-target' @@ -26,15 +25,10 @@ export async function sendMessageToAgent(args: { }) } // Why: queued on the chat's own outbox, as its composer does, so the message shows in the - // chat. The open chat delivers it; marked as this caller's, a failed send stays there without - // a Retry, since the caller keeps what it sent and sends it again. - const entry = appendStructuredAgentSessionOutboxMessage(target.sessionId, prompt, [], 'surface') - if (!entry) { + // chat and a failed send stays there with Retry. The open chat delivers it. + if (!appendStructuredAgentSessionOutboxMessage(target.sessionId, prompt)) { return { status: 'not-writable', code: 'session-outbox-unsaved' } } relaunchFailedStructuredAgentSessionForMessage(worktreeId, target.sessionId) - // A chat at rest starts its agent on this message, which may fail: sent means the agent took it. - return (await awaitStructuredSourcedMessageTaken(target.sessionId, entry.clientMessageId)) - ? { status: 'sent' } - : { status: 'not-taken', code: 'session-message-not-taken' } + return { status: 'sent' } } diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt-handover.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt-handover.ts new file mode 100644 index 000000000000..e941f4b6b30b --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt-handover.ts @@ -0,0 +1,92 @@ +// When a launch prompt counts as delivered: once the agent has taken it. A chat's first message +// starts its agent, and the host answers the send when it accepts the message, before that start; +// a caller that writes outward on delivery (GitHub replies, cleared notes) must not do so for an +// agent that never started. So the wait follows the message to its own final state, read from the +// session's publication: no new wire field, and no timer of its own. A start the host retries +// keeps the message queued, and the retry schedule ends in a verdict. + +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { subscribeStructuredAgentSession } from '@/runtime/structured-agent-session-client' + +type LaunchPromptVerdict = 'taken' | 'not-taken' | 'waiting' + +export function structuredLaunchPromptVerdict( + submission: Pick +): LaunchPromptVerdict { + switch (submission.dispatchState) { + case 'accepted': + return 'taken' + // Unknown is a hand-over whose answer was lost, which a late echo can still prove taken. + case 'pending': + case 'unknown': + return 'waiting' + // A start that failed for good, or a chat closed first. + case 'rejected': + return 'not-taken' + } +} + +// One read per launch prompt, whoever asks. +const inFlight = new Map>() + +/** Whether the chat's agent took the launch prompt: only accepted or rejected answer it. Anything + * that ends the read first (the stream ending or failing) answers no: the caller keeps what it + * would have written. */ +export function awaitStructuredLaunchPromptTaken( + sessionId: string, + clientMessageId: string +): Promise { + const key = `${sessionId}:${clientMessageId}` + const existing = inFlight.get(key) + if (existing) { + return existing + } + const read = readUntilFinal(sessionId, clientMessageId).finally(() => inFlight.delete(key)) + inFlight.set(key, read) + return read +} + +function readUntilFinal(sessionId: string, clientMessageId: string): Promise { + return new Promise((resolve) => { + let settled = false + let unsubscribe: (() => void) | null = null + const finish = (taken: boolean): void => { + if (settled) { + return + } + settled = true + unsubscribe?.() + resolve(taken) + } + const read = (submissions: readonly AgentJournalSubmission[]): void => { + const submission = submissions.find((entry) => entry.clientMessageId === clientMessageId) + const verdict = submission ? structuredLaunchPromptVerdict(submission) : 'waiting' + if (verdict !== 'waiting') { + finish(verdict === 'taken') + } + } + subscribeStructuredAgentSession( + { kind: 'local' }, + { sessionId }, + (event) => { + if (event.type === 'snapshot' || event.type === 'reset') { + read(event.page.submissions) + } else if (event.type === 'batch') { + read(event.batch.submissions) + } else { + finish(false) + } + }, + () => finish(false), + () => finish(false) + ).then( + (handle) => { + unsubscribe = handle.unsubscribe + if (settled) { + handle.unsubscribe() + } + }, + () => finish(false) + ) + }) +} diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt-test-support.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt-test-support.ts index fff5adeb6e2e..70d3459ca3ae 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt-test-support.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt-test-support.ts @@ -73,22 +73,21 @@ export type FirstMessageStream = { * Answers the send as accepted-but-queued and hands back the stream the host then publishes: a * snapshot first, as the host's subscribe always opens, then batches. `opened` is what the message * already is when the reader subscribes, e.g. taken in the gap between the send's answer and then. - * A message whose id is minted by the code under test names it once the reader subscribes. */ export function firstMessageStream( client: LaunchPromptClientMock, - clientMessageId: string | (() => string), + clientMessageId: string, opened: Partial = {} ): Promise { - const idNow = (): string => - typeof clientMessageId === 'function' ? clientMessageId() : clientMessageId - client.call.mockImplementation(async () => ({ + const answered: AgentJournalSubmission = { clientMessageId, ...QUEUED } + let current: AgentJournalSubmission = { ...answered, ...opened } + client.call.mockResolvedValue({ ok: true, replayed: false, fence: 1, cursor: { epoch: 'epoch-1', sequence: 1 }, - value: { clientMessageId: idNow(), submission: { clientMessageId: idNow(), ...QUEUED } } - })) + value: { clientMessageId, submission: answered } + }) return new Promise((resolve) => { client.subscribe.mockImplementation( async ( @@ -96,7 +95,6 @@ export function firstMessageStream( _params: unknown, onEvent: (e: AgentSessionSubscribeEvent) => void ) => { - let current: AgentJournalSubmission = { clientMessageId: idNow(), ...QUEUED, ...opened } let subscribed = true const publish = (submission: AgentJournalSubmission): void => onEvent({ diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts index 8dbb894f0949..14d00f7fe103 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts @@ -17,7 +17,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ })) import { settleStructuredAgentLaunchPrompt } from './structured-agent-session-launch-prompt' -import { awaitStructuredSourcedMessageTaken } from './structured-agent-session-sourced-message-taken' +import { awaitStructuredLaunchPromptTaken } from './structured-agent-session-launch-prompt-handover' import { FIRST_START_FAILS, firstMessageStream, @@ -131,8 +131,8 @@ describe('settleStructuredAgentLaunchPrompt', () => { it('reads one launch prompt once, however many ask', async () => { const stream = firstMessageStream(mocks, 'message-1') - const first = awaitStructuredSourcedMessageTaken('session-1', 'message-1') - const second = awaitStructuredSourcedMessageTaken('session-1', 'message-1') + const first = awaitStructuredLaunchPromptTaken('session-1', 'message-1') + const second = awaitStructuredLaunchPromptTaken('session-1', 'message-1') play(await stream, FIRST_START_FAILS.retriedThenTaken) await expect(Promise.all([first, second])).resolves.toEqual([true, true]) diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index 4402d8c216df..534dc11cfa35 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -18,9 +18,9 @@ import { import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { createBrowserUuid } from '@/lib/browser-uuid' import { - awaitStructuredSourcedMessageTaken, - structuredSourcedMessageVerdict -} from '@/lib/structured-agent-session-sourced-message-taken' + awaitStructuredLaunchPromptTaken, + structuredLaunchPromptVerdict +} from '@/lib/structured-agent-session-launch-prompt-handover' export type StructuredPromptDeliveryResult = { delivered: boolean @@ -201,13 +201,13 @@ export function settleStructuredAgentLaunchPrompt(args: { (await dispatch.promise) || (answer !== undefined && answer !== 'queued' && answer.dispatchState === 'unknown') const verdict = - answer === undefined || answer === 'queued' ? null : structuredSourcedMessageVerdict(answer) + answer === undefined || answer === 'queued' ? null : structuredLaunchPromptVerdict(answer) const delivered = admitted && (answer === 'queued' || verdict === 'taken' || (verdict !== 'not-taken' && - (await awaitStructuredSourcedMessageTaken(entry.sessionId, entry.clientMessageId)))) + (await awaitStructuredLaunchPromptTaken(entry.sessionId, entry.clientMessageId)))) if (delivered) { args.options.onPromptDelivered?.() } diff --git a/src/renderer/src/lib/structured-agent-session-sourced-message-taken.ts b/src/renderer/src/lib/structured-agent-session-sourced-message-taken.ts deleted file mode 100644 index 603847178f1f..000000000000 --- a/src/renderer/src/lib/structured-agent-session-sourced-message-taken.ts +++ /dev/null @@ -1,128 +0,0 @@ -// When a message sent to a chat from somewhere else counts as delivered: once the agent has taken -// it. A launch's prompt, or review notes sent to a chat at rest, start its agent, and the host -// answers the send when it accepts the message, before that start; a source that writes outward on -// delivery (GitHub replies, cleared notes) must not do so for an agent that never started. So the -// wait follows the message to its own final state, read from the session's publication and the -// chat's outbox: no new wire field, and no timer of its own. A start the host retries keeps the -// message queued, and the retry schedule ends in a verdict. - -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import type { AgentSessionQueuedMessage } from '../../../shared/agent-session-queued-message-wire' -import type { StructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox' -import { structuredAgentSessionEntryHeldForRetry } from '../../../shared/structured-agent-session-outbox-admission' -import { subscribeToStructuredAgentSessionOutboxCommits } from '@/components/native-chat/structured-agent-session-outbox-storage' -import { subscribeStructuredAgentSession } from '@/runtime/structured-agent-session-client' - -type SourcedMessageVerdict = 'taken' | 'not-taken' | 'waiting' - -export function structuredSourcedMessageVerdict( - submission: Pick -): SourcedMessageVerdict { - switch (submission.dispatchState) { - case 'accepted': - return 'taken' - // Unknown is a hand-over whose answer was lost, which a late echo can still prove taken. - case 'pending': - case 'unknown': - return 'waiting' - // A start that failed for good, or a chat closed first. - case 'rejected': - return 'not-taken' - } -} - -/** The outbox's word on the message: one it holds as not sent waits only for a Retry its source - * owns. Gone from the outbox, the journal has it. */ -function outboxVerdict( - entries: readonly StructuredAgentSessionOutboxEntry[], - clientMessageId: string -): SourcedMessageVerdict { - const entry = entries.find((candidate) => candidate.clientMessageId === clientMessageId) - return entry && (entry.state === 'rejected' || structuredAgentSessionEntryHeldForRetry(entry)) - ? 'not-taken' - : 'waiting' -} - -// One read per message, whoever asks. -const inFlight = new Map>() - -/** Whether the chat's agent took the message: only accepted, held as a draft behind a running turn, - * or not sent answer it. Anything that ends the read first (the stream ending or failing) answers - * no: the source keeps what it would have written. */ -export function awaitStructuredSourcedMessageTaken( - sessionId: string, - clientMessageId: string -): Promise { - const key = `${sessionId}:${clientMessageId}` - const existing = inFlight.get(key) - if (existing) { - return existing - } - const read = readUntilFinal(sessionId, clientMessageId).finally(() => inFlight.delete(key)) - inFlight.set(key, read) - return read -} - -function readUntilFinal(sessionId: string, clientMessageId: string): Promise { - return new Promise((resolve) => { - let settled = false - let unsubscribe: (() => void) | null = null - const finish = (taken: boolean): void => { - if (settled) { - return - } - settled = true - stopOutbox() - unsubscribe?.() - resolve(taken) - } - const stopOutbox = subscribeToStructuredAgentSessionOutboxCommits(sessionId, (entries) => { - if (outboxVerdict(entries, clientMessageId) === 'not-taken') { - finish(false) - } - }) - const read = ( - submissions: readonly AgentJournalSubmission[], - queued: readonly AgentSessionQueuedMessage[] | null | undefined - ): void => { - // A draft the host holds behind a running turn is that live agent's, and its card owns the - // rest; a returned one failed to send. - const draft = queued?.find((candidate) => candidate.messageId === clientMessageId) - if (draft) { - finish(draft.state === 'waiting') - return - } - const submission = submissions.find( - (entry) => - entry.clientMessageId === clientMessageId || entry.queuedMessageId === clientMessageId - ) - const verdict = submission ? structuredSourcedMessageVerdict(submission) : 'waiting' - if (verdict !== 'waiting') { - finish(verdict === 'taken') - } - } - subscribeStructuredAgentSession( - { kind: 'local' }, - { sessionId }, - (event) => { - if (event.type === 'snapshot' || event.type === 'reset') { - read(event.page.submissions, event.queuedMessages ?? event.page.queuedMessages) - } else if (event.type === 'batch') { - read(event.batch.submissions, event.queuedMessages) - } else { - finish(false) - } - }, - () => finish(false), - () => finish(false) - ).then( - (handle) => { - unsubscribe = handle.unsubscribe - if (settled) { - handle.unsubscribe() - } - }, - () => finish(false) - ) - }) -} diff --git a/src/renderer/src/store/slices/ui-agent-send-target.test.ts b/src/renderer/src/store/slices/ui-agent-send-target.test.ts index 5eb1b664e0d3..61f98a2afbad 100644 --- a/src/renderer/src/store/slices/ui-agent-send-target.test.ts +++ b/src/renderer/src/store/slices/ui-agent-send-target.test.ts @@ -12,7 +12,6 @@ const mocks = vi.hoisted(() => ({ sendNotesToActiveAgentSession: vi.fn(), appendStructuredAgentSessionOutboxMessage: vi.fn(), relaunchFailedStructuredAgentSessionForMessage: vi.fn(), - awaitStructuredSourcedMessageTaken: vi.fn(), track: vi.fn(), toastMessage: vi.fn(), toastSuccess: vi.fn(), @@ -31,10 +30,6 @@ vi.mock('@/components/native-chat/structured-agent-session-outbox-storage', () = appendStructuredAgentSessionOutboxMessage: mocks.appendStructuredAgentSessionOutboxMessage })) -vi.mock('@/lib/structured-agent-session-sourced-message-taken', () => ({ - awaitStructuredSourcedMessageTaken: mocks.awaitStructuredSourcedMessageTaken -})) - vi.mock('@/lib/structured-agent-session-launch', () => ({ relaunchFailedStructuredAgentSessionForMessage: mocks.relaunchFailedStructuredAgentSessionForMessage @@ -63,8 +58,6 @@ beforeEach(() => { mocks.appendStructuredAgentSessionOutboxMessage.mockReset() mocks.appendStructuredAgentSessionOutboxMessage.mockReturnValue({ clientMessageId: 'queued' }) mocks.relaunchFailedStructuredAgentSessionForMessage.mockReset() - mocks.awaitStructuredSourcedMessageTaken.mockReset() - mocks.awaitStructuredSourcedMessageTaken.mockResolvedValue(true) mocks.track.mockReset() mocks.toastMessage.mockReset() mocks.toastSuccess.mockReset() @@ -396,11 +389,8 @@ describe('createUISlice agent send target mode', () => { expect(mocks.appendStructuredAgentSessionOutboxMessage).toHaveBeenCalledWith( 'claude_1', - 'Review this', - [], - 'surface' + 'Review this' ) - expect(mocks.awaitStructuredSourcedMessageTaken).toHaveBeenCalledWith('claude_1', 'queued') expect(mocks.relaunchFailedStructuredAgentSessionForMessage).toHaveBeenCalledWith( worktreeId, 'claude_1' diff --git a/src/shared/agent-session-refusal-retry.test.ts b/src/shared/agent-session-refusal-retry.test.ts index f132d10a6e7a..79020e5695f0 100644 --- a/src/shared/agent-session-refusal-retry.test.ts +++ b/src/shared/agent-session-refusal-retry.test.ts @@ -103,24 +103,3 @@ describe("the owner's verdict decides a retry's operation id only as a floor", ( expect(agentSessionOwnerVerdictAllowsFreshOperationId('exited', 'unverifiable')).toBe(true) }) }) - -describe('a refused message sent from another surface', () => { - const fromNotes = { ...entry, source: 'surface' as const } - - it('keeps its id, since only its source sends it again', () => { - const refused = requeueStructuredAgentSessionSendRefusal( - fromNotes, - { kind: 'refused', code: 'agent_session_operation_invalid' }, - () => 'message-2' - ) - expect(refused).toMatchObject({ clientMessageId: 'message-1', state: 'rejected' }) - expect(retried('exited')).toMatchObject({ clientMessageId: 'message-2' }) - expect( - requeueStructuredAgentSessionSendRefusal( - fromNotes, - storedOwnershipRefusal('exited'), - () => 'message-2' - ) - ).toMatchObject({ clientMessageId: 'message-1', state: 'queued' }) - }) -}) diff --git a/src/shared/structured-agent-session-outbox-source.ts b/src/shared/structured-agent-session-outbox-source.ts index eb8a6195bce2..f3cf51077ce4 100644 --- a/src/shared/structured-agent-session-outbox-source.ts +++ b/src/shared/structured-agent-session-outbox-source.ts @@ -1,5 +1,5 @@ -// An outbox entry sent from outside the chat (a launch's prompt, notes sent to it): its source -// still holds it as unsent until the agent takes it, and is where it is sent again. +// A launch prompt's outbox entry: its source (notes, review comments, a fix action) still holds it +// as unsent until the agent takes it, and is where it is sent again. import type { StructuredAgentSessionAttemptFailure, diff --git a/src/shared/structured-agent-session-outbox-stop-withdrawal.ts b/src/shared/structured-agent-session-outbox-stop-withdrawal.ts index a396ec61f22c..d82cd3475804 100644 --- a/src/shared/structured-agent-session-outbox-stop-withdrawal.ts +++ b/src/shared/structured-agent-session-outbox-stop-withdrawal.ts @@ -71,7 +71,7 @@ export function withdrawUnsentStructuredAgentSessionOutboxEntries( ) { return [markedOutlivingStop(entry)] } - return entry.source === undefined ? [] : [withdrawnFromItsSource(entry)] + return entry.source === 'launch' ? [withdrawnFromItsSource(entry)] : [] }) } diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index c070b53e632e..e8fe17fa2cd7 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -25,8 +25,6 @@ export type StructuredAgentSessionOutboxState = | 'unconfirmed' | 'rejected' -export type StructuredAgentSessionOutboxSource = 'launch' | 'surface' - export type StructuredAgentSessionOutboxEntry = { clientMessageId: string sessionId: string @@ -36,9 +34,7 @@ export type StructuredAgentSessionOutboxEntry = { queuedAt: number lastAttemptAt: number | null retryAfterUnknownSubmittedAt: number | null - /** Sent from outside the chat, which owns sending it again: a launch's prompt (`launch`), or a - * message another surface sent to this chat (`surface`), such as review notes. */ - source?: StructuredAgentSessionOutboxSource + source?: 'launch' /** A Stop landed after this queue send went out: only the user's Retry sends it again, never the * drain, the unconfirmed probe or an owner change, which would start a turn the user stopped. */ outlivedStop?: true @@ -192,13 +188,12 @@ export function requeueStructuredAgentSessionSendRefusal( const ownerExited = refusal.code === 'agent_session_ownership_unknown' && agentSessionOwnerVerdictAllowsFreshOperationId(refusal.details?.ownerVerdict) - // Its source sends it again as a new message, never this entry, so its id stays what it waits on. - if (entry.source !== undefined && refusalSettled) { + // Its source sends it again as a new message, so refused for good it stays here as not sent. + if (entry.source === 'launch' && refusalSettled) { return { ...entry, state: 'rejected' } } if ( !(refusalSettled || ownerExited) || - entry.source !== undefined || retainOperationId || entry.state === 'unconfirmed' || entry.retryAfterUnknownSubmittedAt !== null @@ -232,7 +227,7 @@ export function reconcileStructuredAgentSessionOutbox( classifyDispatchRejection(submission).category === 'withdrawn' ) { const failure = structuredAgentSessionRejectedFailure(submission) - return entry.source === undefined ? [] : [withdrawnFromItsSource(entry, failure)] + return entry.source === 'launch' ? [withdrawnFromItsSource(entry, failure)] : [] } if (submission?.dispatchState === 'pending') { if (entry.state === 'dispatching') { @@ -307,7 +302,7 @@ export function parseStructuredAgentSessionOutboxEntry( typeof entry.retryAfterUnknownSubmittedAt === 'number' ? entry.retryAfterUnknownSubmittedAt : null, - ...(entry.source === 'launch' || entry.source === 'surface' ? { source: entry.source } : {}), + ...(entry.source === 'launch' ? { source: 'launch' as const } : {}), ...parseStructuredAgentSessionOutboxQueueFields(entry), ...(lastFailure ? { lastFailure } : {}) } From 798dd8b515a33973cfb2fd0bb3568f466b3c0fec Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 17:35:40 -0700 Subject: [PATCH 47/84] fix(native-chat): a Claude chat's first start names no model until the agent or a pick does While a Claude chat's first start ran, its options answered from the static seed and named the seed's default (Sonnet) as the current model, so the picker read "Sonnet" with its efforts until the agent reported Opus. The seed's default is not this agent's: Claude's settings or env may run another. With no pick, no reported model and no listing from the agent itself, the model is now unnamed, as a chat at rest already answers, and the picker shows its neutral Model pill. A started agent's own listing still names its default. --- ...claude-structured-effort-reporting.test.ts | 3 ++- .../claude/claude-structured-options.test.ts | 23 +++++++++++++++++++ .../claude-structured-session-options.ts | 5 +++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/main/claude/claude-structured-effort-reporting.test.ts b/src/main/claude/claude-structured-effort-reporting.test.ts index 2c319c86ee72..8ca595612977 100644 --- a/src/main/claude/claude-structured-effort-reporting.test.ts +++ b/src/main/claude/claude-structured-effort-reporting.test.ts @@ -88,7 +88,8 @@ describe('Claude effort reporting', () => { const options = await adapter.readOptions({ sessionId: 'session-1', fence: 7 }) expect(options.current.effort).toBeUndefined() - expect(options.current.model).toBeTruthy() + // Neither the agent's report nor its own listing named a model, so none is named. + expect(options.current.model).toBe('') }) it('keeps the init fixture free of an effort the real frame never sends', async () => { diff --git a/src/main/claude/claude-structured-options.test.ts b/src/main/claude/claude-structured-options.test.ts index e5b7a7ea8a77..64265f556a7f 100644 --- a/src/main/claude/claude-structured-options.test.ts +++ b/src/main/claude/claude-structured-options.test.ts @@ -480,3 +480,26 @@ describe('Claude structured option restore under the request deadline', () => { ).rejects.toBeInstanceOf(ClaudeControlRequestTimeoutError) }) }) + +describe("a Claude chat's first start, before the agent names its model", () => { + function startingSession(): ClaudeSession { + return { ...sessionFor(vi.fn()), startup: createClaudeSessionStartup() } + } + + it("names no model, not its listing's default, and still lists the models", async () => { + const result = await readClaudeStructuredSessionOptions(startingSession(), undefined) + + expect(result.current.model).toBe('') + expect(result.models.length).toBeGreaterThan(0) + expect(result.models.some((model) => model.isDefault)).toBe(true) + }) + + it('names the pick a start replays', async () => { + const session = startingSession() + session.options.set('model', 'opus') + + const result = await readClaudeStructuredSessionOptions(session, undefined) + + expect(result.current.model).toBe('opus') + }) +}) diff --git a/src/main/claude/claude-structured-session-options.ts b/src/main/claude/claude-structured-session-options.ts index 082f45726fe5..4ddaa5e8b639 100644 --- a/src/main/claude/claude-structured-session-options.ts +++ b/src/main/claude/claude-structured-session-options.ts @@ -335,7 +335,10 @@ export function claudeStructuredSessionOptionsFrom( writeClaudeCatalogThrough(session, discovered) const listed = discovered.length > 0 ? discovered : seedModels() const current = readClaudeCurrentModel(session) - const model = currentModelId(listed, current.id) + // The static seed's default is not this agent's: Claude's settings or env may run another, so + // until a pick, the agent's report or its own listing names one (a start still under way), none. + const model = + current.id === undefined && discovered.length === 0 ? '' : currentModelId(listed, current.id) const models = structuredAgentSessionOptionModels(listed, model, (row) => ({ ...row, resolvedModel: null From 871f6dd971893d49f3e28aee4c105998e72d56dd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:05:57 -0700 Subject: [PATCH 48/84] fix(native-chat): a composer's launch prompt keeps the chat's Retry and Stop's composer copy Q3's no-Retry rule and R3N2's Stop rule applied to every launch prompt, including the one typed in the new-workspace and folder-workspace composers. Those composers drop the text once the workspace exists, so a failed start said "Send it again from where you started it" with nowhere to send from, and Stop lost the text. Only a launch whose caller has a delivery consumer keeps the prompt: one told of its delivery (onPromptDelivered: notes, browser annotations, quick launch, session continuation) or one that waits on it (promptHeldBySource: the checks-panel actions, fix checks, recovery, and a work-item launch that waits for delivery). Its outbox entry records that as heldBySource, and only such an entry gets no in-chat Retry, no composer copy at a Stop, and stays as not sent when refused for good. A composer's prompt keeps the chat's Retry, which re-sends the same message, and a Stop puts it back in the composer. Entries saved before this carry no mark and keep Retry. --- ...red-agent-session-delivery-notices.test.ts | 11 +++++++- ...ructured-agent-session-delivery-notices.ts | 8 +++--- ...structured-agent-session-outbox-storage.ts | 12 ++++++--- ...session-withdrawn-message-restore.test.tsx | 21 ++++++++++++--- ...agent-session-withdrawn-message-restore.ts | 4 +-- ...omment-resolution-launch-delivery.test.tsx | 4 +++ .../runSourceControlAgentActionStart.ts | 4 ++- .../source-control/ai/recovery-launch.ts | 3 ++- .../src/lib/agent-session-launch-plan.ts | 9 +++++-- .../src/lib/fix-checks-agent-launch.ts | 1 + .../src/lib/launch-agent-in-new-tab.ts | 7 ++++- ...unch-work-item-direct-route-preparation.ts | 5 +++- .../src/lib/launch-work-item-direct.ts | 3 ++- ...structured-agent-session-launch-callers.ts | 16 +++++++++++ ...agent-session-launch-join-delivery.test.ts | 27 +++++++++++++++++++ ...ctured-agent-session-launch-prompt.test.ts | 4 +-- .../lib/structured-agent-session-launch.ts | 14 +++------- .../structured-agent-session-outbox-source.ts | 2 +- ...ed-agent-session-outbox-stop-withdrawal.ts | 2 +- src/shared/structured-agent-session-outbox.ts | 9 +++++-- 20 files changed, 128 insertions(+), 38 deletions(-) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index 08696c06123a..9118d6844816 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -580,7 +580,13 @@ describe('the notice on a launch prompt the host rejected for good', () => { const retry = vi.fn() const notices = structuredAgentSessionDeliveryNotices( [ - entry('launch', { source: 'launch', state: 'rejected', lastFailure: notInstalled }), + entry('launch', { + source: 'launch', + heldBySource: true, + state: 'rejected', + lastFailure: notInstalled + }), + entry('composer', { source: 'launch', state: 'rejected', lastFailure: notInstalled }), entry('typed', { state: 'rejected', lastFailure: notInstalled }) ], 'Claude', @@ -595,6 +601,8 @@ describe('the notice on a launch prompt the host rejected for good', () => { expect(launch?.text).toBe( "Not sent: Claude isn't installed. Install it first. Send it again from where you started it." ) + // A composer's launch prompt has nowhere else to be sent from: it keeps the chat's Retry. + expect(notices.get(agentJournalSubmissionKey('composer'))?.onRetry).toBeTypeOf('function') // A message typed in the chat keeps its own Retry, and the same step to take first. const typed = notices.get(agentJournalSubmissionKey('typed')) expect(typed?.onRetry).toBeTypeOf('function') @@ -606,6 +614,7 @@ describe('the notice on a launch prompt the host rejected for good', () => { [ entry('launch', { source: 'launch', + heldBySource: true, state: 'queued', lastFailure: { kind: 'refused', code: 'agent_session_operation_capacity' } }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts index 4d3ecc52f802..d98230a57a9c 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts @@ -181,11 +181,11 @@ function deliveryNoticeText( ) } -/** A launch prompt the host rejected for good belongs to where it was sent from (notes, review - * comments, a fix action): that source still holds it as unsent and is where it goes again, so a - * Retry here would deliver it while the source never learns it went. */ +/** A launch prompt the host rejected for good whose caller still holds it (notes, review comments, + * a fix action) is sent again from there, so a Retry here would deliver it while that caller never + * learns it went. A composer's prompt has no such caller and keeps its Retry. */ function sentFromSource(entry: StructuredAgentSessionOutboxEntry): boolean { - return entry.source === 'launch' && entry.state === 'rejected' + return entry.heldBySource === true && entry.state === 'rejected' } function sourcedMessageNotSentText(reason: string): string { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts index a28749eed20a..3cc7be566559 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts @@ -250,7 +250,7 @@ export function appendStructuredAgentSessionOutboxMessage( sessionId: string, text: string, attachments: readonly StructuredAgentSessionAttachment[] = [], - source?: 'launch' + marking: Pick = {} ): StructuredAgentSessionOutboxEntry | null { const entry = { ...createStructuredAgentSessionOutboxEntry({ @@ -260,7 +260,7 @@ export function appendStructuredAgentSessionOutboxMessage( attachments, queuedAt: Date.now() }), - ...(source ? { source } : {}) + ...marking } return commitStructuredAgentSessionOutbox( sessionId, @@ -273,9 +273,13 @@ export function appendStructuredAgentSessionOutboxMessage( export function enqueueStructuredAgentSessionLaunchPrompt( sessionId: string, - text: string + text: string, + heldBySource = false ): StructuredAgentSessionOutboxEntry | null { - return appendStructuredAgentSessionOutboxMessage(sessionId, text, [], 'launch') + return appendStructuredAgentSessionOutboxMessage(sessionId, text, [], { + source: 'launch', + ...(heldBySource ? { heldBySource: true } : {}) + }) } export function discardStructuredAgentSessionLaunchOutbox(sessionId: string): void { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx index 199093a228a3..c07d5c2efeb7 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx @@ -280,9 +280,9 @@ describe('a message the host withdrew at a Stop', () => { ['a composer shows the chat', PANE], ['no composer shows the chat', null] ])( - 'keeps a launch prompt a Stop withdrew while its start retried as not sent, when %s', + 'keeps a launch prompt its caller holds, which a Stop withdrew while its start retried, as not sent, when %s', (_case, composer) => { - const launch = enqueueStructuredAgentSessionLaunchPrompt(SESSION, 'launch text')! + const launch = enqueueStructuredAgentSessionLaunchPrompt(SESSION, 'launch text', true)! const id = launch.clientMessageId const { result, rerender } = renderOutbox(composer) rerender({ @@ -323,6 +323,19 @@ describe('a message the host withdrew at a Stop', () => { }) } ) + + it("gives a composer's launch prompt a Stop withdrew back to the composer, which holds nothing else", () => { + const launch = enqueueStructuredAgentSessionLaunchPrompt(SESSION, 'launch text')! + const { result, rerender } = renderOutbox() + rerender({ submissions: [submission(launch.clientMessageId)] }) + + rerender({ + submissions: [withdrawn(launch.clientMessageId, { rejection: { kind: 'cancelled' } })] + }) + + expect(readNativeChatDraftCache(PANE)).toBe('launch text') + expect(result.current.outbox).toEqual([]) + }) }) describe('a message a Stop took out of the outbox before the host held it', () => { @@ -367,8 +380,8 @@ describe('a message a Stop took out of the outbox before the host held it', () = expect(readNativeChatDraftCache(PANE)).toBe('hello') }) - it('keeps a launch prompt not yet sent as not sent, and gives it nothing in the composer', () => { - const launch = enqueueStructuredAgentSessionLaunchPrompt(SESSION, 'launch text')! + it('keeps a launch prompt its caller holds, not yet sent, as not sent, and gives it nothing in the composer', () => { + const launch = enqueueStructuredAgentSessionLaunchPrompt(SESSION, 'launch text', true)! mocks.call.mockImplementation(() => new Promise(() => {})) const { result } = renderOutbox() diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts index 94337a62586e..2836df040663 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts @@ -25,8 +25,8 @@ function restoreWithdrawnMessages( getStructuredAgentSessionOutbox(sessionId).map((entry) => entry.clientMessageId) ) for (const entry of withdrawn) { - // A launch prompt goes back to its source, which still holds it, not to the composer. - if (!held.has(entry.clientMessageId) || entry.source === 'launch') { + // A launch prompt its caller still holds goes back there, not to the composer. + if (!held.has(entry.clientMessageId) || entry.heldBySource === true) { continue } const blocks = entry.body.blocks diff --git a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx index 62a09a26646a..3cf36daa4b1c 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx @@ -170,6 +170,10 @@ describe('Resolve comments with AI, when the chat starts on its first message', it('posts its reply and resolves its thread exactly once, after a retried start takes the prompt', async () => { const { model, hook } = acknowledgement() const { host, launched } = resolveCommentsWithAi(hook) + // Its replies wait on the delivery, so the chat leaves the prompt to it rather than its Retry. + expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith( + expect.objectContaining({ promptHeldBySource: true }) + ) const stream = await host const [retry, handedOver, accepted] = FIRST_START_FAILS.retriedThenTaken diff --git a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts index 2f5c044ab1d7..b5146001429d 100644 --- a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts @@ -109,7 +109,9 @@ export async function runSourceControlAgentActionStart({ agentArgs: launchAgentArgs, promptDelivery, launchPlatform, - launchSource + launchSource, + // Its caller's payload (review replies, a check fix) waits on the delivery below. + promptHeldBySource: true }) launched = Boolean(result) if (result?.surface.kind === 'local-terminal') { diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts b/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts index bbfe8a4e876b..f6302b0f1702 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts @@ -164,7 +164,8 @@ export async function launchSourceControlRecoveryAgentWithDefault({ agentArgs: savedRecipe.agentArgs, promptDelivery: 'submit-after-ready', launchPlatform: activeSourceControlLaunchPlatform, - launchSource: 'source_control_recovery' + launchSource: 'source_control_recovery', + promptHeldBySource: true }) if (!result) { toast.error(copy.launchCommandUnavailable) diff --git a/src/renderer/src/lib/agent-session-launch-plan.ts b/src/renderer/src/lib/agent-session-launch-plan.ts index 7e72412e4fb6..f6972bad3516 100644 --- a/src/renderer/src/lib/agent-session-launch-plan.ts +++ b/src/renderer/src/lib/agent-session-launch-plan.ts @@ -24,6 +24,8 @@ import type { StructuredAgentLaunchOptions } from '@/lib/structured-agent-sessio export type AgentSessionLaunchRequest = AgentLaunchRouteArgs & { resumeFrom?: StructuredAgentSessionResumeSource onPromptDelivered?: () => void + /** See `StructuredAgentLaunchOptions.promptHeldBySource`. */ + promptHeldBySource?: boolean } /** @@ -39,6 +41,7 @@ export type AgentSessionLaunchVerdict = { promptDelivery?: NativeChatLaunchPromptDelivery resumeFrom?: StructuredAgentSessionResumeSource onPromptDelivered?: () => void + promptHeldBySource?: boolean } export type AgentSessionStructuredFeasibilityRequest = AgentLaunchRouteArgs & { @@ -70,7 +73,8 @@ function structuredLaunchOptions(verdict: AgentSessionLaunchVerdict): Structured ...(verdict.prompt !== undefined ? { prompt: verdict.prompt } : {}), ...(verdict.promptDelivery ? { promptDelivery: verdict.promptDelivery } : {}), ...(verdict.resumeFrom ? { resumeFrom: verdict.resumeFrom } : {}), - ...(verdict.onPromptDelivered ? { onPromptDelivered: verdict.onPromptDelivered } : {}) + ...(verdict.onPromptDelivered ? { onPromptDelivered: verdict.onPromptDelivered } : {}), + ...(verdict.promptHeldBySource ? { promptHeldBySource: true } : {}) } } @@ -135,6 +139,7 @@ export function planAgentSessionLaunch( ...(request.prompt !== undefined ? { prompt: request.prompt } : {}), ...(request.promptDelivery ? { promptDelivery: request.promptDelivery } : {}), ...(request.resumeFrom ? { resumeFrom: request.resumeFrom } : {}), - ...(request.onPromptDelivered ? { onPromptDelivered: request.onPromptDelivered } : {}) + ...(request.onPromptDelivered ? { onPromptDelivered: request.onPromptDelivered } : {}), + ...(request.promptHeldBySource ? { promptHeldBySource: true } : {}) }) } diff --git a/src/renderer/src/lib/fix-checks-agent-launch.ts b/src/renderer/src/lib/fix-checks-agent-launch.ts index 83460e8b9888..d3c2f9be372d 100644 --- a/src/renderer/src/lib/fix-checks-agent-launch.ts +++ b/src/renderer/src/lib/fix-checks-agent-launch.ts @@ -197,6 +197,7 @@ export async function startFixChecksAgent(args: StartFixChecksAgentArgs): Promis promptDelivery: 'submit-after-ready', launchPlatform, launchSource: args.launchSource, + promptHeldBySource: true, beforeSurfaceOpen: () => { // Why: the launcher owns the initial surface, so revealing must not seed a sibling shell. const revealed = activateAndRevealWorktree(targetWorktreeId, { diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index ce28d3c8ad18..3f6853566835 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -52,6 +52,9 @@ export type LaunchAgentInNewTabArgs = { launchPlatform?: NodeJS.Platform /** Called after the prompt is actually delivered to the agent input path. */ onPromptDelivered?: () => void + /** The caller keeps the prompt until `promptDeliveryResult` says it went, and sends it again + * itself; a structured chat then offers it no Retry of its own. */ + promptHeldBySource?: boolean /** * Called before `onPromptDelivered` when the paste was written without ever observing the * agent's composer, so the launch cannot claim the prompt arrived. Fires only on the @@ -115,6 +118,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent launchPlatform, onPromptDelivered, onPromptDeliveryUnconfirmed, + promptHeldBySource, agentSessionLaunchPlan, pendingActivationSpawn, beforeSurfaceOpen @@ -209,7 +213,8 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent promptDelivery: viewModePromptDelivery, tuiCustomization: { cwd: initialCwd }, initialSessionOptions: startupPlan.sessionOptions, - onPromptDelivered + onPromptDelivered, + ...(promptHeldBySource ? { promptHeldBySource } : {}) }) if (plan?.route === 'structured-native-chat') { const structured = launchAgentInStructuredNewTab({ diff --git a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts index 96ea10d91ec2..861727f7ea90 100644 --- a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts +++ b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts @@ -39,6 +39,8 @@ export async function prepareDirectWorkItemAgentLaunch(args: { launchPlatform?: NodeJS.Platform repoProjectRuntime?: Parameters[0]['repoProjectRuntime'] planLaunch: typeof planAgentSessionLaunch + /** The caller waits on the prompt's delivery and keeps it until then. */ + promptHeldBySource?: boolean }): Promise { const launchConnectionId = getConnectionId(args.worktreeId) ?? args.repoConnectionId const agentSelection = await resolveDirectWorkItemAgent({ @@ -97,7 +99,8 @@ export async function prepareDirectWorkItemAgentLaunch(args: { workspace: { kind: 'git-worktree', worktreeId: args.worktreeId, repoId: args.repoId }, prompt: args.draftContent, promptDelivery: args.promptDelivery, - initialSessionOptions: startupPlan?.sessionOptions + initialSessionOptions: startupPlan?.sessionOptions, + ...(args.promptHeldBySource ? { promptHeldBySource: true } : {}) }) const structuredLaunch = plan?.route === 'structured-native-chat' diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index c913a0da628c..cb200a157f7a 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -216,7 +216,8 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom promptDelivery, launchPlatform: args.launchPlatform, repoProjectRuntime, - planLaunch: planAgentSessionLaunch + planLaunch: planAgentSessionLaunch, + ...(args.waitForPromptDelivery ? { promptHeldBySource: true } : {}) }) if (launchPreparation.unavailable) { activateAndRevealWorktree(worktreeId, { diff --git a/src/renderer/src/lib/structured-agent-session-launch-callers.ts b/src/renderer/src/lib/structured-agent-session-launch-callers.ts index db3f930d0fbe..8daa595288a9 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-callers.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-callers.ts @@ -1,4 +1,5 @@ import { settleStructuredAgentLaunchPrompt } from '@/lib/structured-agent-session-launch-prompt' +import { enqueueStructuredAgentSessionLaunchPrompt } from '@/components/native-chat/structured-agent-session-outbox-storage' import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-session-launch-prompt' import type { StructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox' import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' @@ -7,11 +8,26 @@ export type StructuredAgentLaunchOptions = { prompt?: string promptDelivery?: 'auto-submit' | 'submit-after-ready' | 'draft' onPromptDelivered?: () => void + /** The caller keeps the prompt, or a side effect, until `promptDeliveryResult` says it went, so + * it is where a failed prompt is sent again. `onPromptDelivered` implies it. */ + promptHeldBySource?: boolean /** Adopt an existing provider conversation instead of starting a fresh one. Part of the launch's * identity, not a preference — see `launchIdentity`. */ resumeFrom?: StructuredAgentSessionResumeSource } +/** Queues a launch's prompt on its chat's outbox. A caller with a delivery consumer keeps the + * prompt until it hears it went, so the entry is marked as held by it. */ +export function stageStructuredLaunchPrompt( + sessionId: string, + text: string, + options: StructuredAgentLaunchOptions +): StructuredAgentSessionOutboxEntry | null { + const heldBySource = + options.onPromptDelivered !== undefined || options.promptHeldBySource === true + return text ? enqueueStructuredAgentSessionLaunchPrompt(sessionId, text, heldBySource) : null +} + export type StructuredLaunchCaller = { promptDeliveryResult?: Promise } diff --git a/src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts b/src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts index 3a82f0b390c2..60ffaa17fdda 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts @@ -176,3 +176,30 @@ describe('coalesced launch delivery mode', () => { ) }) }) + +describe("who holds a launch's prompt", () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + mocks.launch.mockImplementation(() => new Promise(() => {})) + }) + + it.each([ + ['a composer, which keeps nothing once the chat has it', {}, undefined], + ['a caller told of its delivery', { onPromptDelivered: () => {} }, true], + ['a caller that waits on its delivery', { promptHeldBySource: true }, true] + ] as const)('marks it held only by %s', (_case, caller, heldBySource) => { + const sessionId = `held-${String(heldBySource)}-${Object.keys(caller).join('')}` + mocks.createIntent.mockReturnValueOnce(launchIntent(`wt-${sessionId}`, sessionId)) + + startStructuredAgentLaunch(`wt-${sessionId}`, 'codex', { + prompt: 'review this', + promptDelivery: 'auto-submit', + ...caller + }) + + const [entry] = readOutbox(sessionId) + expect(entry).toMatchObject({ source: 'launch' }) + expect(entry?.heldBySource).toBe(heldBySource) + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts index 14d00f7fe103..d0335c57e4f4 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts @@ -167,8 +167,8 @@ describe('settleStructuredAgentLaunchPrompt', () => { ) }) - it('keeps why a launch prompt was refused for good, so its notice says it', async () => { - const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'review this') + it('keeps why a launch prompt its caller holds was refused for good, so its notice says it', async () => { + const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'review this', true) mocks.call.mockResolvedValue({ ok: false, refusal: { code: 'agent_session_operation_invalid', message: 'invalid' } diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index 10a37d46c4c3..6356d6f04218 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -6,10 +6,7 @@ import { retryStructuredAgentSessionLaunchIntent, StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' -import { - discardStructuredAgentSessionLaunchOutbox, - enqueueStructuredAgentSessionLaunchPrompt -} from '@/components/native-chat/structured-agent-session-outbox-storage' +import { discardStructuredAgentSessionLaunchOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage' import { launchAndReconcile, reconcileUnknownLaunch, @@ -21,6 +18,7 @@ import { createStructuredLaunchCallerGroup, releaseStructuredLaunchCallerAfterUnknownOutcome, settleStructuredLaunchCallers, + stageStructuredLaunchPrompt, structuredLaunchCallersHavePendingWork, type StructuredAgentLaunchOptions, type StructuredLaunchCaller @@ -207,9 +205,7 @@ function structuredAgentLaunchState( const joined = joinLaunchDelivery(options, existing.promptDelivery) // Why: failed launches keep their draft/outbox, so a retry must not stage the same prompt twice. const text = retrying ? '' : outboxPromptText(joined) - const stagedPrompt = text - ? enqueueStructuredAgentSessionLaunchPrompt(existing.intent.sessionId, text) - : null + const stagedPrompt = stageStructuredLaunchPrompt(existing.intent.sessionId, text, joined) if (!retrying) { launchDraft.seedStructuredAgentLaunchDraft(existing.intent.sessionId, agent, joined) } @@ -233,9 +229,7 @@ function structuredAgentLaunchState( ? createStructuredAgentSessionLaunchIntent(worktreeId, agent, options.resumeFrom) : createStructuredAgentSessionLaunchIntent(worktreeId, agent) const text = outboxPromptText(options) - const stagedPrompt = text - ? enqueueStructuredAgentSessionLaunchPrompt(intent.sessionId, text) - : null + const stagedPrompt = stageStructuredLaunchPrompt(intent.sessionId, text, options) launchDraft.seedStructuredAgentLaunchDraft(intent.sessionId, agent, options) const callers = createStructuredLaunchCallerGroup() const state: StructuredLaunchState = { diff --git a/src/shared/structured-agent-session-outbox-source.ts b/src/shared/structured-agent-session-outbox-source.ts index f3cf51077ce4..8c81dcf7e2dd 100644 --- a/src/shared/structured-agent-session-outbox-source.ts +++ b/src/shared/structured-agent-session-outbox-source.ts @@ -1,4 +1,4 @@ -// A launch prompt's outbox entry: its source (notes, review comments, a fix action) still holds it +// A launch prompt whose caller holds it (notes, review comments, a fix action): that caller keeps it // as unsent until the agent takes it, and is where it is sent again. import type { diff --git a/src/shared/structured-agent-session-outbox-stop-withdrawal.ts b/src/shared/structured-agent-session-outbox-stop-withdrawal.ts index d82cd3475804..846b1eed7fac 100644 --- a/src/shared/structured-agent-session-outbox-stop-withdrawal.ts +++ b/src/shared/structured-agent-session-outbox-stop-withdrawal.ts @@ -71,7 +71,7 @@ export function withdrawUnsentStructuredAgentSessionOutboxEntries( ) { return [markedOutlivingStop(entry)] } - return entry.source === 'launch' ? [withdrawnFromItsSource(entry)] : [] + return entry.heldBySource === true ? [withdrawnFromItsSource(entry)] : [] }) } diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index e8fe17fa2cd7..a90d17d87346 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -35,6 +35,10 @@ export type StructuredAgentSessionOutboxEntry = { lastAttemptAt: number | null retryAfterUnknownSubmittedAt: number | null source?: 'launch' + /** A launch prompt whose caller keeps it, or a side effect, until it is delivered (notes, a review + * reply, a fix action): that caller is where it is sent again, so the chat offers no Retry and + * a Stop gives it no composer copy. A composer's own prompt has none and keeps both. */ + heldBySource?: true /** A Stop landed after this queue send went out: only the user's Retry sends it again, never the * drain, the unconfirmed probe or an owner change, which would start a turn the user stopped. */ outlivedStop?: true @@ -189,7 +193,7 @@ export function requeueStructuredAgentSessionSendRefusal( refusal.code === 'agent_session_ownership_unknown' && agentSessionOwnerVerdictAllowsFreshOperationId(refusal.details?.ownerVerdict) // Its source sends it again as a new message, so refused for good it stays here as not sent. - if (entry.source === 'launch' && refusalSettled) { + if (entry.heldBySource === true && refusalSettled) { return { ...entry, state: 'rejected' } } if ( @@ -227,7 +231,7 @@ export function reconcileStructuredAgentSessionOutbox( classifyDispatchRejection(submission).category === 'withdrawn' ) { const failure = structuredAgentSessionRejectedFailure(submission) - return entry.source === 'launch' ? [withdrawnFromItsSource(entry, failure)] : [] + return entry.heldBySource === true ? [withdrawnFromItsSource(entry, failure)] : [] } if (submission?.dispatchState === 'pending') { if (entry.state === 'dispatching') { @@ -303,6 +307,7 @@ export function parseStructuredAgentSessionOutboxEntry( ? entry.retryAfterUnknownSubmittedAt : null, ...(entry.source === 'launch' ? { source: 'launch' as const } : {}), + ...(entry.heldBySource === true ? { heldBySource: true as const } : {}), ...parseStructuredAgentSessionOutboxQueueFields(entry), ...(lastFailure ? { lastFailure } : {}) } From 59e4d2171bfcc414dcd658f2c43b3cf1c0a4f786 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:14:12 -0700 Subject: [PATCH 49/84] fix(native-chat): a session continuation's prompt keeps the chat's Retry RD1 read any caller with onPromptDelivered as holding the prompt. Session continuation passes it only to toast success; its dialog keeps no copy once launched, and a failed delivery offers only "Copy prompt" in a transient toast. So it now says promptHeldBySource: false, which overrides the onPromptDelivered default: its prompt keeps the chat's Retry, which re-sends the same message, and a Stop gives it back to the composer. An explicit false now travels through launchAgentInNewTab and the launch plan instead of being dropped. The checks-panel marking assertion moves after the launch settles, so an ablation fails that one test instead of aborting mid-launch and failing the six tests that run after it. --- .../comment-resolution-launch-delivery.test.tsx | 9 +++++---- src/renderer/src/lib/agent-session-launch-plan.ts | 8 ++++++-- src/renderer/src/lib/launch-agent-in-new-tab.ts | 5 +++-- .../src/lib/launch-agent-session-continuation.test.ts | 4 +++- .../src/lib/launch-agent-session-continuation.ts | 2 ++ .../lib/structured-agent-session-launch-callers.ts | 6 +++--- ...uctured-agent-session-launch-join-delivery.test.ts | 11 ++++++++--- 7 files changed, 30 insertions(+), 15 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx index 3cf36daa4b1c..da3b27bf3d71 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx @@ -170,10 +170,6 @@ describe('Resolve comments with AI, when the chat starts on its first message', it('posts its reply and resolves its thread exactly once, after a retried start takes the prompt', async () => { const { model, hook } = acknowledgement() const { host, launched } = resolveCommentsWithAi(hook) - // Its replies wait on the delivery, so the chat leaves the prompt to it rather than its Retry. - expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith( - expect.objectContaining({ promptHeldBySource: true }) - ) const stream = await host const [retry, handedOver, accepted] = FIRST_START_FAILS.retriedThenTaken @@ -192,6 +188,11 @@ describe('Resolve comments with AI, when the chat starts on its first message', await vi.waitFor(() => expect(model.resolveReviewThread).toHaveBeenCalledTimes(1)) expect(model.addPRConversationComment).toHaveBeenCalledTimes(1) expect(model.pendingCommentResolutionRef.current).toBeNull() + // Its replies wait on the delivery, so the chat leaves the prompt to it rather than its Retry. + // Checked once the launch has settled, so a failure here leaves no launch running. + expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith( + expect.objectContaining({ promptHeldBySource: true }) + ) }) const endings: [string, readonly FirstMessageChange[]][] = [ diff --git a/src/renderer/src/lib/agent-session-launch-plan.ts b/src/renderer/src/lib/agent-session-launch-plan.ts index f6972bad3516..47d78e808473 100644 --- a/src/renderer/src/lib/agent-session-launch-plan.ts +++ b/src/renderer/src/lib/agent-session-launch-plan.ts @@ -74,7 +74,9 @@ function structuredLaunchOptions(verdict: AgentSessionLaunchVerdict): Structured ...(verdict.promptDelivery ? { promptDelivery: verdict.promptDelivery } : {}), ...(verdict.resumeFrom ? { resumeFrom: verdict.resumeFrom } : {}), ...(verdict.onPromptDelivered ? { onPromptDelivered: verdict.onPromptDelivered } : {}), - ...(verdict.promptHeldBySource ? { promptHeldBySource: true } : {}) + ...(verdict.promptHeldBySource !== undefined + ? { promptHeldBySource: verdict.promptHeldBySource } + : {}) } } @@ -140,6 +142,8 @@ export function planAgentSessionLaunch( ...(request.promptDelivery ? { promptDelivery: request.promptDelivery } : {}), ...(request.resumeFrom ? { resumeFrom: request.resumeFrom } : {}), ...(request.onPromptDelivered ? { onPromptDelivered: request.onPromptDelivered } : {}), - ...(request.promptHeldBySource ? { promptHeldBySource: true } : {}) + ...(request.promptHeldBySource !== undefined + ? { promptHeldBySource: request.promptHeldBySource } + : {}) }) } diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 3f6853566835..8c86f007e885 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -53,7 +53,8 @@ export type LaunchAgentInNewTabArgs = { /** Called after the prompt is actually delivered to the agent input path. */ onPromptDelivered?: () => void /** The caller keeps the prompt until `promptDeliveryResult` says it went, and sends it again - * itself; a structured chat then offers it no Retry of its own. */ + * itself; a structured chat then offers it no Retry of its own. Defaults to whether the caller + * passes `onPromptDelivered`; `false` is one told of delivery only to say so. */ promptHeldBySource?: boolean /** * Called before `onPromptDelivered` when the paste was written without ever observing the @@ -214,7 +215,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent tuiCustomization: { cwd: initialCwd }, initialSessionOptions: startupPlan.sessionOptions, onPromptDelivered, - ...(promptHeldBySource ? { promptHeldBySource } : {}) + ...(promptHeldBySource !== undefined ? { promptHeldBySource } : {}) }) if (plan?.route === 'structured-native-chat') { const structured = launchAgentInStructuredNewTab({ diff --git a/src/renderer/src/lib/launch-agent-session-continuation.test.ts b/src/renderer/src/lib/launch-agent-session-continuation.test.ts index 0634680cf520..b26a81c88770 100644 --- a/src/renderer/src/lib/launch-agent-session-continuation.test.ts +++ b/src/renderer/src/lib/launch-agent-session-continuation.test.ts @@ -75,7 +75,9 @@ describe('launchAgentSessionContinuation', () => { worktreeId: 'wt-1', groupId: 'group-1', initialCwd: '/repo/worktree/packages/app', - promptDelivery: 'draft' + promptDelivery: 'draft', + // Its dialog keeps no copy: a chat's own Retry, not this launch, sends it again. + promptHeldBySource: false }) ) }) diff --git a/src/renderer/src/lib/launch-agent-session-continuation.ts b/src/renderer/src/lib/launch-agent-session-continuation.ts index 5656eddca040..38c4645ade1e 100644 --- a/src/renderer/src/lib/launch-agent-session-continuation.ts +++ b/src/renderer/src/lib/launch-agent-session-continuation.ts @@ -91,6 +91,8 @@ export async function launchAgentSessionContinuation({ promptDelivery: agent === 'claude' ? 'draft' : 'submit-after-ready', launchSource, ...(initialCwd ? { initialCwd } : {}), + // Its dialog keeps no copy once launched, so the chat's own Retry is how it is sent again. + promptHeldBySource: false, onPromptDeliveryUnconfirmed: () => { deliveryUnconfirmed = true }, diff --git a/src/renderer/src/lib/structured-agent-session-launch-callers.ts b/src/renderer/src/lib/structured-agent-session-launch-callers.ts index 8daa595288a9..92492527df0f 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-callers.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-callers.ts @@ -9,7 +9,8 @@ export type StructuredAgentLaunchOptions = { promptDelivery?: 'auto-submit' | 'submit-after-ready' | 'draft' onPromptDelivered?: () => void /** The caller keeps the prompt, or a side effect, until `promptDeliveryResult` says it went, so - * it is where a failed prompt is sent again. `onPromptDelivered` implies it. */ + * it is where a failed prompt is sent again. Defaults to whether it passes `onPromptDelivered`; + * `false` is a caller told of delivery only to say so, which keeps nothing to send again. */ promptHeldBySource?: boolean /** Adopt an existing provider conversation instead of starting a fresh one. Part of the launch's * identity, not a preference — see `launchIdentity`. */ @@ -23,8 +24,7 @@ export function stageStructuredLaunchPrompt( text: string, options: StructuredAgentLaunchOptions ): StructuredAgentSessionOutboxEntry | null { - const heldBySource = - options.onPromptDelivered !== undefined || options.promptHeldBySource === true + const heldBySource = options.promptHeldBySource ?? options.onPromptDelivered !== undefined return text ? enqueueStructuredAgentSessionLaunchPrompt(sessionId, text, heldBySource) : null } diff --git a/src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts b/src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts index 60ffaa17fdda..6bab0a084db4 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts @@ -187,9 +187,14 @@ describe("who holds a launch's prompt", () => { it.each([ ['a composer, which keeps nothing once the chat has it', {}, undefined], ['a caller told of its delivery', { onPromptDelivered: () => {} }, true], - ['a caller that waits on its delivery', { promptHeldBySource: true }, true] - ] as const)('marks it held only by %s', (_case, caller, heldBySource) => { - const sessionId = `held-${String(heldBySource)}-${Object.keys(caller).join('')}` + ['a caller that waits on its delivery', { promptHeldBySource: true }, true], + [ + 'a caller told of its delivery only to say so', + { onPromptDelivered: () => {}, promptHeldBySource: false }, + undefined + ] + ] as const)('marks it held only by %s', (named, caller, heldBySource) => { + const sessionId = `held-${named.replace(/\W+/g, '-')}` mocks.createIntent.mockReturnValueOnce(launchIntent(`wt-${sessionId}`, sessionId)) startStructuredAgentLaunch(`wt-${sessionId}`, 'codex', { From 1385b3d217785a3b53305218f22b0becc91665d6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:14:55 -0700 Subject: [PATCH 50/84] test(native-chat): the launch plan hands on a caller's explicit promptHeldBySource, false included --- .../src/lib/agent-session-launch-plan.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/renderer/src/lib/agent-session-launch-plan.test.ts b/src/renderer/src/lib/agent-session-launch-plan.test.ts index 73b475229bfb..031b9f403c88 100644 --- a/src/renderer/src/lib/agent-session-launch-plan.test.ts +++ b/src/renderer/src/lib/agent-session-launch-plan.test.ts @@ -90,6 +90,26 @@ describe('planAgentSessionLaunch', () => { ) }) + it.each([true, false])( + 'hands the settle loop a caller saying it holds the prompt: %s', + async (held) => { + const plan = planAgentSessionLaunch(store, { + agent: 'claude', + workspace: { kind: 'folder', worktreeId: 'folder:ws-1' }, + prompt: 'Review this', + promptHeldBySource: held + }) + + await plan.launch(hooks) + expect(mocks.beginStructuredAgentLaunchSettlement).toHaveBeenCalledWith( + 'folder:ws-1', + 'claude', + expect.objectContaining({ promptHeldBySource: held }), + hooks + ) + } + ) + it('sends no delivery fields the request did not carry', async () => { const plan = planAgentSessionLaunch(store, { agent: 'codex', From f4e6b366fd995d700247dda87f1314df262fcb02 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:27:27 -0700 Subject: [PATCH 51/84] fix(native-chat): a desktop that reads a pending send as delivered is answered once its start settles Temporary. A desktop that advertises accepted sends reads a `pending` reply as delivered, and a chat's first message now starts its agent after the host accepts it, so that desktop would run a launch prompt's side effects (review replies, cleared notes) before the start. Current desktops advertise a new capability, agent-session.send-final-state.v1, because they follow the message to its final state themselves. For one without it, the host holds the send's reply until the message is handed over, rejected, or queued behind a command, waiting through start retries (a new `past-start` settlement point), for at most 10 s: under the 15 s such a desktop gives a send to a remote host. A start still under way then is answered `unknown`, without `recovered`, which that desktop reads as not delivered and resends only under the same id, answered from the ledger. No released desktop advertises structured chats to a remote host today, so the hold guards only a release that would add them before this one. The send capabilities move into their own module, spread into the protocol lists, so protocol-version stays within its line budget. --- .../desktop-renderer-runtime-capabilities.ts | 2 + ...tructured-agent-session-send-settlement.ts | 22 +- ...ctured-agent-session-send-compatibility.ts | 40 +- src/shared/agent-session-send-capabilities.ts | 31 ++ src/shared/protocol-version.ts | 21 +- .../accepted-send-hold-downgrade.unit.test.ts | 346 ++++++++++++++++++ ...ss-version-agent-session-wire.unit.test.ts | 6 +- 7 files changed, 440 insertions(+), 28 deletions(-) create mode 100644 src/shared/agent-session-send-capabilities.ts create mode 100644 tests/e2e/cross-version-wire/accepted-send-hold-downgrade.unit.test.ts diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.ts index 8fe881e00614..1558a72140c3 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.ts @@ -4,6 +4,7 @@ import { AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + AGENT_SESSION_SEND_FINAL_STATE_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, @@ -28,6 +29,7 @@ export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapab AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, + AGENT_SESSION_SEND_FINAL_STATE_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts index 48d3f5840095..dc40688f735b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts @@ -22,13 +22,15 @@ type SendSettlement = SettledSend | 'pending' | 'missing' * the message waiting behind a running command, which hands nothing over until it ends. A failed * start recorded on the message ends each: the host answers it now, while it waits for its next try. * `verdict` alone waits through those tries, for the message's own answer, and `final` through a - * lost answer (`unknown`) too, which a late echo can still prove accepted. */ + * lost answer (`unknown`) too, which a late echo can still prove accepted. `past-start` is + * handed over or behind a command, waiting through the tries: a started agent has it. */ export type SendSettlementPoint = | 'answered' | 'handed-over' | 'handed-over-or-behind-command' | 'verdict' | 'final' + | 'past-start' export type SendSettlementWaitOptions = { signal?: AbortSignal @@ -66,14 +68,16 @@ function settledSend( return 'missing' } const waiting = - until === 'verdict' || until === 'final' - ? submission.dispatchState === 'pending' || - (until === 'final' && submission.dispatchState === 'unknown') - : !isRetryingStructuredAgentSessionStart(submission) && - (until === 'answered' - ? submission.dispatchState === 'pending' - : isQueuedAgentJournalSubmission(submission) && - !(until === 'handed-over-or-behind-command' && runningCommand(journal))) + until === 'past-start' + ? isQueuedAgentJournalSubmission(submission) && !runningCommand(journal) + : until === 'verdict' || until === 'final' + ? submission.dispatchState === 'pending' || + (until === 'final' && submission.dispatchState === 'unknown') + : !isRetryingStructuredAgentSessionStart(submission) && + (until === 'answered' + ? submission.dispatchState === 'pending' + : isQueuedAgentJournalSubmission(submission) && + !(until === 'handed-over-or-behind-command' && runningCommand(journal))) return waiting ? 'pending' : { cursor: journal.cursor(), value: { clientMessageId, submission } } } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts index 47afaa8ac735..797986bf610a 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts @@ -1,6 +1,7 @@ import { AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + AGENT_SESSION_SEND_FINAL_STATE_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { agentSessionSendSubmission } from '../../../../shared/agent-session-wire' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' @@ -8,17 +9,27 @@ import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from '../../../native-chat/age import type { RpcContext } from '../core' import { requireStructuredHost, structuredCallerFor } from './structured-agent-session-gate' +/** Under the 15 s a released desktop gives a send to a remote host, with room for the request. */ +export const ACCEPTED_SEND_CLIENT_HOLD_MS = 10_000 + /** * A send answers once the host accepts it. A client that predates that answer cannot show a * message rejected after it, so its reply is held until the message is handed over or rejected — * or queued behind a running command such as `/compact`, which could outlast the client's own * request timeout; one that predates pending replies at all waits, as before, for the provider's * answer. + * + * Temporary: a desktop with only `accepted-send` reads `pending` as delivered, and a chat's first + * message now starts its agent after that answer, so its reply waits past the start, up to + * `holdMs`. A start still under way then is answered `unknown`, which that client reads as not + * delivered and resends only under the same id, which the host answers from its ledger. */ export async function sendStructuredAgentSessionForClient( params: Parameters[1], - context: RpcContext + context: RpcContext, + holdMs = ACCEPTED_SEND_CLIENT_HOLD_MS ) { + const startedAt = Date.now() const host = requireStructuredHost(context) // Only a client's own send lifts a Stop's queue pause; host-internal senders never do. const result = await host.send(structuredCallerFor(context), { ...params, userSend: true }) @@ -28,10 +39,31 @@ export async function sendStructuredAgentSessionForClient( // A queued answer only ever reaches a capable client, which renders it as-is. agentSessionSendSubmission(result.value)?.dispatchState !== 'pending' || context.clientKind === undefined || - capabilities.includes(AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY) + capabilities.includes(AGENT_SESSION_SEND_FINAL_STATE_RUNTIME_CAPABILITY) ) { return result } + const signal = context.signal ? { signal: context.signal } : {} + if (capabilities.includes(AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY)) { + const settled = await host.waitForSendSettlement( + params.envelope.sessionId, + result.value.clientMessageId, + { until: 'past-start', budgetMs: Math.max(0, holdMs - (Date.now() - startedAt)), ...signal } + ) + if (settled) { + return { ...result, ...settled } + } + const submission = agentSessionSendSubmission(result.value) + return submission + ? { + ...result, + value: { + ...result.value, + submission: { ...submission, dispatchState: 'unknown' as const } + } + } + : result + } // The start that used to run before the reply now runs after acceptance, so both waits cover it. const settled = await host.waitForSendSettlement( params.envelope.sessionId, @@ -41,7 +73,7 @@ export async function sendStructuredAgentSessionForClient( ? 'handed-over-or-behind-command' : 'answered', budgetMs: STRUCTURED_AGENT_SESSION_START_WAIT_MS, - ...(context.signal ? { signal: context.signal } : {}) + ...signal } ) return settled ? { ...result, ...settled } : result diff --git a/src/shared/agent-session-send-capabilities.ts b/src/shared/agent-session-send-capabilities.ts new file mode 100644 index 000000000000..7542696d0200 --- /dev/null +++ b/src/shared/agent-session-send-capabilities.ts @@ -0,0 +1,31 @@ +// What a structured send's reply means to the client that asked, negotiated per client: each later +// client reads more of the host's answer on its own, so the host holds less of it back. + +// Why: older structured clients render durable pending replies as uncertain delivery. Capable +// clients skip the host's bounded best-effort settlement observation. +export const AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY = + 'agent-session.pending-send-result.v1' as const +// Why: a send is now answered once the host accepts it, before any agent has it. A client without +// this cannot show a message rejected after that answer, so the host holds its reply until the +// message is handed over or rejected. Transitional: drop the hold once no supported desktop or +// mobile client lacks the capability; mobile must first show a rejected message in place. +export const AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY = + 'agent-session.accepted-send.v1' as const +// Why: a chat's first message now starts its agent, after the host accepts it. A client with this +// follows a send to its final state before writing outward (notes cleared, review replies posted); +// one with only `accepted-send` reads `pending` as delivered, so the host holds that one's reply +// past the start, up to a cap. Temporary: drop the hold once no supported desktop lacks this. +export const AGENT_SESSION_SEND_FINAL_STATE_RUNTIME_CAPABILITY = + 'agent-session.send-final-state.v1' as const + +/** The host side: it answers pending sends, and accepts a send before any agent has it. */ +export const AGENT_SESSION_SEND_HOST_CAPABILITIES = [ + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY +] as const + +/** What a desktop reads of a send's reply, advertised to a remote host. */ +export const AGENT_SESSION_SEND_CLIENT_CAPABILITIES = [ + ...AGENT_SESSION_SEND_HOST_CAPABILITIES, + AGENT_SESSION_SEND_FINAL_STATE_RUNTIME_CAPABILITY +] as const diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 416b53ed84b7..4cbe36fa2a70 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -1,5 +1,10 @@ import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from './agent-session-background-task-child-views-capability' import { AGENT_SESSION_STOP_RUNTIME_CAPABILITIES } from './agent-session-stop-capabilities' +import { + AGENT_SESSION_SEND_CLIENT_CAPABILITIES, + AGENT_SESSION_SEND_HOST_CAPABILITIES +} from './agent-session-send-capabilities' +export * from './agent-session-send-capabilities' import { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update' import { SKILL_BUNDLE_INSTALL_CAPABILITY, @@ -164,16 +169,6 @@ export const AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY = // receive their journal or drive their lifecycle. Mobile may receive a metadata-only placeholder; // the host still refuses agentSession.* methods and destructive tab mutations without capability. export const STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = 'agent-session.structured.v1' as const -// Why: older structured clients render durable pending replies as uncertain delivery. Capable -// clients skip the host's bounded best-effort settlement observation. -export const AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY = - 'agent-session.pending-send-result.v1' as const -// Why: a send is now answered once the host accepts it, before any agent has it. A client without -// this cannot show a message rejected after that answer, so the host holds its reply until the -// message is handed over or rejected. Transitional: drop the hold once no supported desktop or -// mobile client lacks the capability; mobile must first show a rejected message in place. -export const AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY = - 'agent-session.accepted-send.v1' as const // Why: `agentSession.send`'s params are strict, so an older host rejects `delivery`; and only a // capable client can render the `queued` result arm, the draft list, and returned cards. DARK ON // PURPOSE — not in RUNTIME_CAPABILITIES: advertising still requires the integrated Codex steer @@ -332,8 +327,7 @@ export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ // host still requires the separate authenticated browser-client lease. export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ ...NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, - AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, + ...AGENT_SESSION_SEND_CLIENT_CAPABILITIES, BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, // Why: only the renderer runs the retirement-proof ledger; CLI and mobile must keep full lists. @@ -407,10 +401,9 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, AGENT_SESSION_KEYBOARD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, // The host side: it accepts a send before any agent has it, and a Stop with no writer before a // turn starts, so a client may gate on either. - AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, + ...AGENT_SESSION_SEND_HOST_CAPABILITIES, STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, diff --git a/tests/e2e/cross-version-wire/accepted-send-hold-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/accepted-send-hold-downgrade.unit.test.ts new file mode 100644 index 000000000000..8f1ef4772089 --- /dev/null +++ b/tests/e2e/cross-version-wire/accepted-send-hold-downgrade.unit.test.ts @@ -0,0 +1,346 @@ +// A released desktop (accepted sends, no final-state reads) sending a chat's first message to this +// host. That client reads `pending` as delivered, and the message now starts its agent after the +// host accepts it, so the host holds its reply past the start, up to a cap, and answers a start +// still under way as `unknown`. Read with the released client's own outbox code: it keeps the +// message under the same id, resends only that id, and the host answers it from its ledger. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { + AgentSessionPreSpawnError, + type StructuredAgentSessionAdapter +} from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter' +import { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host' +import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' +import { createStructuredAgentSessionLogger } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' +import { openTestJournalHostDatabase } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' +import type { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store-test-harness' +import { ACCEPTED_SEND_CLIENT_HOLD_MS } from '../../../src/main/runtime/rpc/methods/structured-agent-session-send-compatibility' +import { + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, + AGENT_SESSION_SEND_FINAL_STATE_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY +} from '../../../src/shared/protocol-version' +import { + importReleaseCheckoutModule, + materializeReleaseCheckout, + resolveBaselineReleaseRef, + type ReleaseCheckout +} from './release-checkout' +import { + attachParams, + createIntentParams, + NOW, + resetOperationIds, + SESSION, + THREAD +} from './structured-agent-session-surface-manifest' +import { + loadAgentSessionWireBuild, + WORKING_TREE, + type AgentSessionWireBuild, + type RpcReply +} from './versioned-agent-session-wire' + +// Why: a cold CI run extracts the baseline checkout before the first pairing. +const SUITE_TIMEOUT_MS = 180_000 + +type OutboxEntry = { clientMessageId: string; state: string } +type ReleasedClient = { + capabilities: string[] + createEntry: (args: Record) => OutboxEntry + sendRequest: (entry: OutboxEntry, fence: number) => unknown + dispose: (input: Record) => { entries: OutboxEntry[] } + reconcile: (entries: OutboxEntry[], submissions: unknown[]) => OutboxEntry[] + admit: (entries: OutboxEntry[]) => { state: string } +} + +let current: AgentSessionWireBuild +let released: ReleasedClient + +async function releasedClient(checkout: ReleaseCheckout): Promise { + const [protocol, outbox, disposition] = await Promise.all([ + importReleaseCheckoutModule(checkout, '/src/shared/protocol-version.ts'), + importReleaseCheckoutModule(checkout, '/src/shared/structured-agent-session-outbox.ts'), + importReleaseCheckoutModule( + checkout, + '/src/shared/structured-agent-session-send-disposition.ts' + ) + ]) + return { + // What a released desktop advertises to a remote host, minus the capability this host keys + // the hold on, so the pairing stays exercised once a release ships it. No release advertises + // structured chats to a remote host yet (the host refuses them as clientCapabilityMissing), + // so it is added: the desktop this hold is for is one that does, without final-state reads. + capabilities: [ + ...(protocol.ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES as string[]).filter( + (capability) => capability !== AGENT_SESSION_SEND_FINAL_STATE_RUNTIME_CAPABILITY + ), + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ], + createEntry: outbox.createStructuredAgentSessionOutboxEntry as ReleasedClient['createEntry'], + sendRequest: outbox.structuredAgentSessionSendRequest as ReleasedClient['sendRequest'], + dispose: disposition.disposeStructuredAgentSessionSendResult as ReleasedClient['dispose'], + reconcile: outbox.reconcileStructuredAgentSessionOutbox as ReleasedClient['reconcile'], + admit: outbox.admitStructuredAgentSessionOutboxEntry as ReleasedClient['admit'] + } +} + +beforeAll(async () => { + current = await loadAgentSessionWireBuild(WORKING_TREE) + released = await releasedClient(await materializeReleaseCheckout(resolveBaselineReleaseRef())) +}, SUITE_TIMEOUT_MS) + +describe("a released desktop's first message to a chat at rest", () => { + let root: string + let store: AgentSessionRecordStore + let host: StructuredAgentSessionHost + /** Holds the agent's start open: the start is under way until it opens. */ + let open: () => void + + /** The first start is refused for an account switch, which books another try in 15 s. */ + let switching = false + + function adapter(): StructuredAgentSessionAdapter { + let gate = new Promise((resolve) => (open = resolve)) + return { + supportsCreate: () => true, + acquire: async ({ fence }) => { + if (switching) { + switching = false + throw new AgentSessionPreSpawnError(new Error('switching'), { + reason: 'accountSwitchInProgress' + }) + } + await gate + gate = Promise.resolve() + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex', threadId: THREAD }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + } + } + }, + dispatch: async () => ({ + state: 'accepted', + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } + }), + cancelTurn: async () => ({ cancelled: true }), + answerPrompt: async () => undefined, + setOption: async () => undefined + } + } + + function runtime(): unknown { + return { + getRuntimeId: () => 'runtime-1', + getClientSettings: () => ({ experimentalStructuredNativeChat: true }), + ensureStructuredAgentSessionHost: async () => undefined, + getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), + resolveStructuredAgentSessionCreateIntent: async () => { + const { + envelope: _envelope, + providerHandle: _providerHandle, + ...resolved + } = attachParams(null) + return resolved + }, + publishStructuredAgentSessionTab: () => {} + } + } + + async function call( + method: string, + params: unknown, + capabilities: readonly string[] + ): Promise { + const replies: RpcReply[] = [] + await current + .createDispatcher(runtime()) + .dispatchStreaming( + { id: `request-${method}`, authToken: 'cross-version-token', method, params }, + (raw) => replies.push(JSON.parse(raw) as RpcReply), + { clientKind: 'runtime', clientCapabilities: capabilities, clientId: 'paired-desktop' } + ) + return replies[0]! + } + + async function journalRows() { + return (await host.journalSnapshot(SESSION)).submissions + } + + beforeEach(async () => { + switching = false + resetOperationIds() + root = await mkdtemp(join(tmpdir(), 'orca-cross-version-accepted-send-')) + store = await openTestAgentSessionRecordStore(root) + host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + store, + adapter: adapter(), + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-a', + now: () => NOW + }) + setStructuredAgentSessionHost(host) + }) + + afterEach(async () => { + vi.useRealTimers() + setStructuredAgentSessionHost(null) + await rm(root, { recursive: true, force: true }) + }) + + it('is answered unknown at the cap while its start runs, and only ever resent under its own id', async () => { + expect(released.capabilities).toContain(AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY) + const created = await call('agentSession.create', createIntentParams(), [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ]) + const { fence } = created.result as { fence: number } + const entry = released.createEntry({ + clientMessageId: `${NOW}-${'a'.repeat(32)}`, + sessionId: SESSION, + text: 'review my notes', + attachments: [], + queuedAt: NOW + }) + + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + let reply: RpcReply | undefined + void call('agentSession.send', released.sendRequest(entry, fence), released.capabilities).then( + (answered) => (reply = answered) + ) + await vi.advanceTimersByTimeAsync(ACCEPTED_SEND_CLIENT_HOLD_MS - 1) + // The start is still under way: a `pending` now would read as delivered. + expect(reply).toBeUndefined() + await vi.advanceTimersByTimeAsync(1) + vi.useRealTimers() + expect(reply).toMatchObject({ + ok: true, + result: { + value: { clientMessageId: entry.clientMessageId, submission: { dispatchState: 'unknown' } } + } + }) + + // The released client keeps it as unconfirmed under the same id, holding the queue: no new id. + const disposed = released.dispose({ + entries: [{ ...entry, state: 'dispatching' }], + entry: { ...entry, state: 'dispatching' }, + blockedClientMessageId: null, + result: reply!.result, + createOperationId: () => 'a-new-id-the-client-must-not-use' + }).entries + expect(disposed).toEqual([ + expect.objectContaining({ clientMessageId: entry.clientMessageId, state: 'unconfirmed' }) + ]) + expect(released.admit(disposed).state).toBe('blocked') + // The journal's own row moves it back to waiting on the host, still under the same id. + const waiting = released.reconcile(disposed, await journalRows()) + expect(waiting).toEqual([ + expect.objectContaining({ clientMessageId: entry.clientMessageId, state: 'dispatching' }) + ]) + + // A resend (its probe or its Retry) reuses the id; the host answers it from its ledger. + const resent = call( + 'agentSession.send', + released.sendRequest(entry, fence), + released.capabilities + ) + open() + expect(await resent).toMatchObject({ + ok: true, + result: { value: { clientMessageId: entry.clientMessageId } } + }) + await vi.waitFor(async () => + expect(await journalRows()).toEqual([ + expect.objectContaining({ + clientMessageId: entry.clientMessageId, + dispatchState: 'accepted' + }) + ]) + ) + expect(released.reconcile(waiting, await journalRows())).toEqual([]) + }) + + async function createdChat(): Promise { + const created = await call('agentSession.create', createIntentParams(), [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ]) + return (created.result as { fence: number }).fence + } + + function firstMessage(hex: string): OutboxEntry { + return released.createEntry({ + clientMessageId: `${NOW}-${hex.repeat(32)}`, + sessionId: SESSION, + text: 'review my notes', + attachments: [], + queuedAt: NOW + }) + } + + it('is answered once its start hands it over, within the cap', async () => { + const fence = await createdChat() + const entry = firstMessage('b') + + const reply = call( + 'agentSession.send', + released.sendRequest(entry, fence), + released.capabilities + ) + open() + expect(await reply).toMatchObject({ + ok: true, + result: { value: { submission: { handedOverAt: expect.any(Number) } } } + }) + }) + + it('waits out a start that failed and booked another try, rather than answer it pending', async () => { + const fence = await createdChat() + switching = true + open() + const entry = firstMessage('d') + + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) + let reply: RpcReply | undefined + void call('agentSession.send', released.sendRequest(entry, fence), released.capabilities).then( + (answered) => (reply = answered) + ) + await vi.advanceTimersByTimeAsync(ACCEPTED_SEND_CLIENT_HOLD_MS - 1) + expect(reply).toBeUndefined() + await vi.advanceTimersByTimeAsync(1) + vi.useRealTimers() + expect(reply).toMatchObject({ + ok: true, + result: { value: { submission: { dispatchState: 'unknown' } } } + }) + }) + + it('answers a current desktop at acceptance, while the start still runs', async () => { + const fence = await createdChat() + const entry = firstMessage('c') + + const reply = await call('agentSession.send', released.sendRequest(entry, fence), [ + ...released.capabilities, + AGENT_SESSION_SEND_FINAL_STATE_RUNTIME_CAPABILITY + ]) + expect(reply).toMatchObject({ + ok: true, + result: { value: { submission: { dispatchState: 'pending' } } } + }) + open() + }) +}) diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index f3a78d8547a5..a7fcab5902b5 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -29,6 +29,7 @@ import { AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, AGENT_SESSION_REWIND_RUNTIME_CAPABILITY, + AGENT_SESSION_SEND_FINAL_STATE_RUNTIME_CAPABILITY, AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY, AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY @@ -852,9 +853,12 @@ describe('cross-version structured agent sessions', () => { const restarted = await bootHost('c') startGate = new Promise((resolve) => (open = resolve)) + // Current: it follows the message to its final state itself (a desktop with only accepted + // sends is held past the start: accepted-send-hold-downgrade.unit.test.ts). const currentReply = await call('agentSession.send', sendParams('current', created.fence), [ ...released, - AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, + AGENT_SESSION_SEND_FINAL_STATE_RUNTIME_CAPABILITY ]) expect(currentReply[0]).toMatchObject({ ok: true, From ecdd84e2c440103ce05209555f84737ba9ae1da5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:30:44 -0700 Subject: [PATCH 52/84] test(native-chat): read the baseline client's functions and the create's fence without casts --- .../accepted-send-hold-downgrade.unit.test.ts | 70 ++++++++++++------- 1 file changed, 43 insertions(+), 27 deletions(-) diff --git a/tests/e2e/cross-version-wire/accepted-send-hold-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/accepted-send-hold-downgrade.unit.test.ts index 8f1ef4772089..100431194eeb 100644 --- a/tests/e2e/cross-version-wire/accepted-send-hold-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/accepted-send-hold-downgrade.unit.test.ts @@ -70,22 +70,37 @@ async function releasedClient(checkout: ReleaseCheckout): Promise capability !== AGENT_SESSION_SEND_FINAL_STATE_RUNTIME_CAPABILITY + ...listed.filter( + (capability): capability is string => + typeof capability === 'string' && + capability !== AGENT_SESSION_SEND_FINAL_STATE_RUNTIME_CAPABILITY ), STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY ], - createEntry: outbox.createStructuredAgentSessionOutboxEntry as ReleasedClient['createEntry'], - sendRequest: outbox.structuredAgentSessionSendRequest as ReleasedClient['sendRequest'], - dispose: disposition.disposeStructuredAgentSessionSendResult as ReleasedClient['dispose'], - reconcile: outbox.reconcileStructuredAgentSessionOutbox as ReleasedClient['reconcile'], - admit: outbox.admitStructuredAgentSessionOutboxEntry as ReleasedClient['admit'] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each member was just checked to be a function, and these are the shapes the release's own source declares for them. + ...(members as unknown as Omit) } } @@ -204,12 +219,30 @@ describe("a released desktop's first message to a chat at rest", () => { await rm(root, { recursive: true, force: true }) }) - it('is answered unknown at the cap while its start runs, and only ever resent under its own id', async () => { - expect(released.capabilities).toContain(AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY) + async function createdChat(): Promise { const created = await call('agentSession.create', createIntentParams(), [ STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY ]) - const { fence } = created.result as { fence: number } + const result = created.result + if (!result || typeof result !== 'object' || !('fence' in result)) { + throw new Error(`create answered no fence: ${JSON.stringify(created)}`) + } + return Number(result.fence) + } + + function firstMessage(hex: string): OutboxEntry { + return released.createEntry({ + clientMessageId: `${NOW}-${hex.repeat(32)}`, + sessionId: SESSION, + text: 'review my notes', + attachments: [], + queuedAt: NOW + }) + } + + it('is answered unknown at the cap while its start runs, and only ever resent under its own id', async () => { + expect(released.capabilities).toContain(AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY) + const fence = await createdChat() const entry = released.createEntry({ clientMessageId: `${NOW}-${'a'.repeat(32)}`, sessionId: SESSION, @@ -275,23 +308,6 @@ describe("a released desktop's first message to a chat at rest", () => { expect(released.reconcile(waiting, await journalRows())).toEqual([]) }) - async function createdChat(): Promise { - const created = await call('agentSession.create', createIntentParams(), [ - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY - ]) - return (created.result as { fence: number }).fence - } - - function firstMessage(hex: string): OutboxEntry { - return released.createEntry({ - clientMessageId: `${NOW}-${hex.repeat(32)}`, - sessionId: SESSION, - text: 'review my notes', - attachments: [], - queuedAt: NOW - }) - } - it('is answered once its start hands it over, within the cap', async () => { const fence = await createdChat() const entry = firstMessage('b') From 58395b68d98fe65c84ff6d5141dc374eca873672 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:31:42 -0700 Subject: [PATCH 53/84] Revert "test(native-chat): read the baseline client's functions and the create's fence without casts" This reverts commit ecdd84e2c440103ce05209555f84737ba9ae1da5. --- .../accepted-send-hold-downgrade.unit.test.ts | 70 +++++++------------ 1 file changed, 27 insertions(+), 43 deletions(-) diff --git a/tests/e2e/cross-version-wire/accepted-send-hold-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/accepted-send-hold-downgrade.unit.test.ts index 100431194eeb..8f1ef4772089 100644 --- a/tests/e2e/cross-version-wire/accepted-send-hold-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/accepted-send-hold-downgrade.unit.test.ts @@ -70,37 +70,22 @@ async function releasedClient(checkout: ReleaseCheckout): Promise - typeof capability === 'string' && - capability !== AGENT_SESSION_SEND_FINAL_STATE_RUNTIME_CAPABILITY + ...(protocol.ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES as string[]).filter( + (capability) => capability !== AGENT_SESSION_SEND_FINAL_STATE_RUNTIME_CAPABILITY ), STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY ], - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: each member was just checked to be a function, and these are the shapes the release's own source declares for them. - ...(members as unknown as Omit) + createEntry: outbox.createStructuredAgentSessionOutboxEntry as ReleasedClient['createEntry'], + sendRequest: outbox.structuredAgentSessionSendRequest as ReleasedClient['sendRequest'], + dispose: disposition.disposeStructuredAgentSessionSendResult as ReleasedClient['dispose'], + reconcile: outbox.reconcileStructuredAgentSessionOutbox as ReleasedClient['reconcile'], + admit: outbox.admitStructuredAgentSessionOutboxEntry as ReleasedClient['admit'] } } @@ -219,30 +204,12 @@ describe("a released desktop's first message to a chat at rest", () => { await rm(root, { recursive: true, force: true }) }) - async function createdChat(): Promise { + it('is answered unknown at the cap while its start runs, and only ever resent under its own id', async () => { + expect(released.capabilities).toContain(AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY) const created = await call('agentSession.create', createIntentParams(), [ STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY ]) - const result = created.result - if (!result || typeof result !== 'object' || !('fence' in result)) { - throw new Error(`create answered no fence: ${JSON.stringify(created)}`) - } - return Number(result.fence) - } - - function firstMessage(hex: string): OutboxEntry { - return released.createEntry({ - clientMessageId: `${NOW}-${hex.repeat(32)}`, - sessionId: SESSION, - text: 'review my notes', - attachments: [], - queuedAt: NOW - }) - } - - it('is answered unknown at the cap while its start runs, and only ever resent under its own id', async () => { - expect(released.capabilities).toContain(AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY) - const fence = await createdChat() + const { fence } = created.result as { fence: number } const entry = released.createEntry({ clientMessageId: `${NOW}-${'a'.repeat(32)}`, sessionId: SESSION, @@ -308,6 +275,23 @@ describe("a released desktop's first message to a chat at rest", () => { expect(released.reconcile(waiting, await journalRows())).toEqual([]) }) + async function createdChat(): Promise { + const created = await call('agentSession.create', createIntentParams(), [ + STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY + ]) + return (created.result as { fence: number }).fence + } + + function firstMessage(hex: string): OutboxEntry { + return released.createEntry({ + clientMessageId: `${NOW}-${hex.repeat(32)}`, + sessionId: SESSION, + text: 'review my notes', + attachments: [], + queuedAt: NOW + }) + } + it('is answered once its start hands it over, within the cap', async () => { const fence = await createdChat() const entry = firstMessage('b') From 2b65c04ba02053e941034c41e4a68b3a44ab3c90 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:31:43 -0700 Subject: [PATCH 54/84] Revert "fix(native-chat): a desktop that reads a pending send as delivered is answered once its start settles" This reverts commit f4e6b366fd995d700247dda87f1314df262fcb02. --- .../desktop-renderer-runtime-capabilities.ts | 2 - ...tructured-agent-session-send-settlement.ts | 22 +- ...ctured-agent-session-send-compatibility.ts | 40 +- src/shared/agent-session-send-capabilities.ts | 31 -- src/shared/protocol-version.ts | 21 +- .../accepted-send-hold-downgrade.unit.test.ts | 346 ------------------ ...ss-version-agent-session-wire.unit.test.ts | 6 +- 7 files changed, 28 insertions(+), 440 deletions(-) delete mode 100644 src/shared/agent-session-send-capabilities.ts delete mode 100644 tests/e2e/cross-version-wire/accepted-send-hold-downgrade.unit.test.ts diff --git a/src/main/ipc/desktop-renderer-runtime-capabilities.ts b/src/main/ipc/desktop-renderer-runtime-capabilities.ts index 1558a72140c3..8fe881e00614 100644 --- a/src/main/ipc/desktop-renderer-runtime-capabilities.ts +++ b/src/main/ipc/desktop-renderer-runtime-capabilities.ts @@ -4,7 +4,6 @@ import { AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, - AGENT_SESSION_SEND_FINAL_STATE_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, CLAUDE_STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, @@ -29,7 +28,6 @@ export const DESKTOP_RENDERER_RUNTIME_CLIENT_CAPABILITIES: readonly RuntimeCapab AGENT_SESSION_BACKGROUND_TASK_STOP_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, - AGENT_SESSION_SEND_FINAL_STATE_RUNTIME_CAPABILITY, AGENT_SESSION_TURN_ITEM_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_ROW_STOP_CAPABILITY, AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts index dc40688f735b..48d3f5840095 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts @@ -22,15 +22,13 @@ type SendSettlement = SettledSend | 'pending' | 'missing' * the message waiting behind a running command, which hands nothing over until it ends. A failed * start recorded on the message ends each: the host answers it now, while it waits for its next try. * `verdict` alone waits through those tries, for the message's own answer, and `final` through a - * lost answer (`unknown`) too, which a late echo can still prove accepted. `past-start` is - * handed over or behind a command, waiting through the tries: a started agent has it. */ + * lost answer (`unknown`) too, which a late echo can still prove accepted. */ export type SendSettlementPoint = | 'answered' | 'handed-over' | 'handed-over-or-behind-command' | 'verdict' | 'final' - | 'past-start' export type SendSettlementWaitOptions = { signal?: AbortSignal @@ -68,16 +66,14 @@ function settledSend( return 'missing' } const waiting = - until === 'past-start' - ? isQueuedAgentJournalSubmission(submission) && !runningCommand(journal) - : until === 'verdict' || until === 'final' - ? submission.dispatchState === 'pending' || - (until === 'final' && submission.dispatchState === 'unknown') - : !isRetryingStructuredAgentSessionStart(submission) && - (until === 'answered' - ? submission.dispatchState === 'pending' - : isQueuedAgentJournalSubmission(submission) && - !(until === 'handed-over-or-behind-command' && runningCommand(journal))) + until === 'verdict' || until === 'final' + ? submission.dispatchState === 'pending' || + (until === 'final' && submission.dispatchState === 'unknown') + : !isRetryingStructuredAgentSessionStart(submission) && + (until === 'answered' + ? submission.dispatchState === 'pending' + : isQueuedAgentJournalSubmission(submission) && + !(until === 'handed-over-or-behind-command' && runningCommand(journal))) return waiting ? 'pending' : { cursor: journal.cursor(), value: { clientMessageId, submission } } } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts index 797986bf610a..47afaa8ac735 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-send-compatibility.ts @@ -1,7 +1,6 @@ import { AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, - AGENT_SESSION_SEND_FINAL_STATE_RUNTIME_CAPABILITY + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { agentSessionSendSubmission } from '../../../../shared/agent-session-wire' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' @@ -9,27 +8,17 @@ import { STRUCTURED_AGENT_SESSION_START_WAIT_MS } from '../../../native-chat/age import type { RpcContext } from '../core' import { requireStructuredHost, structuredCallerFor } from './structured-agent-session-gate' -/** Under the 15 s a released desktop gives a send to a remote host, with room for the request. */ -export const ACCEPTED_SEND_CLIENT_HOLD_MS = 10_000 - /** * A send answers once the host accepts it. A client that predates that answer cannot show a * message rejected after it, so its reply is held until the message is handed over or rejected — * or queued behind a running command such as `/compact`, which could outlast the client's own * request timeout; one that predates pending replies at all waits, as before, for the provider's * answer. - * - * Temporary: a desktop with only `accepted-send` reads `pending` as delivered, and a chat's first - * message now starts its agent after that answer, so its reply waits past the start, up to - * `holdMs`. A start still under way then is answered `unknown`, which that client reads as not - * delivered and resends only under the same id, which the host answers from its ledger. */ export async function sendStructuredAgentSessionForClient( params: Parameters[1], - context: RpcContext, - holdMs = ACCEPTED_SEND_CLIENT_HOLD_MS + context: RpcContext ) { - const startedAt = Date.now() const host = requireStructuredHost(context) // Only a client's own send lifts a Stop's queue pause; host-internal senders never do. const result = await host.send(structuredCallerFor(context), { ...params, userSend: true }) @@ -39,31 +28,10 @@ export async function sendStructuredAgentSessionForClient( // A queued answer only ever reaches a capable client, which renders it as-is. agentSessionSendSubmission(result.value)?.dispatchState !== 'pending' || context.clientKind === undefined || - capabilities.includes(AGENT_SESSION_SEND_FINAL_STATE_RUNTIME_CAPABILITY) + capabilities.includes(AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY) ) { return result } - const signal = context.signal ? { signal: context.signal } : {} - if (capabilities.includes(AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY)) { - const settled = await host.waitForSendSettlement( - params.envelope.sessionId, - result.value.clientMessageId, - { until: 'past-start', budgetMs: Math.max(0, holdMs - (Date.now() - startedAt)), ...signal } - ) - if (settled) { - return { ...result, ...settled } - } - const submission = agentSessionSendSubmission(result.value) - return submission - ? { - ...result, - value: { - ...result.value, - submission: { ...submission, dispatchState: 'unknown' as const } - } - } - : result - } // The start that used to run before the reply now runs after acceptance, so both waits cover it. const settled = await host.waitForSendSettlement( params.envelope.sessionId, @@ -73,7 +41,7 @@ export async function sendStructuredAgentSessionForClient( ? 'handed-over-or-behind-command' : 'answered', budgetMs: STRUCTURED_AGENT_SESSION_START_WAIT_MS, - ...signal + ...(context.signal ? { signal: context.signal } : {}) } ) return settled ? { ...result, ...settled } : result diff --git a/src/shared/agent-session-send-capabilities.ts b/src/shared/agent-session-send-capabilities.ts deleted file mode 100644 index 7542696d0200..000000000000 --- a/src/shared/agent-session-send-capabilities.ts +++ /dev/null @@ -1,31 +0,0 @@ -// What a structured send's reply means to the client that asked, negotiated per client: each later -// client reads more of the host's answer on its own, so the host holds less of it back. - -// Why: older structured clients render durable pending replies as uncertain delivery. Capable -// clients skip the host's bounded best-effort settlement observation. -export const AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY = - 'agent-session.pending-send-result.v1' as const -// Why: a send is now answered once the host accepts it, before any agent has it. A client without -// this cannot show a message rejected after that answer, so the host holds its reply until the -// message is handed over or rejected. Transitional: drop the hold once no supported desktop or -// mobile client lacks the capability; mobile must first show a rejected message in place. -export const AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY = - 'agent-session.accepted-send.v1' as const -// Why: a chat's first message now starts its agent, after the host accepts it. A client with this -// follows a send to its final state before writing outward (notes cleared, review replies posted); -// one with only `accepted-send` reads `pending` as delivered, so the host holds that one's reply -// past the start, up to a cap. Temporary: drop the hold once no supported desktop lacks this. -export const AGENT_SESSION_SEND_FINAL_STATE_RUNTIME_CAPABILITY = - 'agent-session.send-final-state.v1' as const - -/** The host side: it answers pending sends, and accepts a send before any agent has it. */ -export const AGENT_SESSION_SEND_HOST_CAPABILITIES = [ - AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, - AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY -] as const - -/** What a desktop reads of a send's reply, advertised to a remote host. */ -export const AGENT_SESSION_SEND_CLIENT_CAPABILITIES = [ - ...AGENT_SESSION_SEND_HOST_CAPABILITIES, - AGENT_SESSION_SEND_FINAL_STATE_RUNTIME_CAPABILITY -] as const diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 4cbe36fa2a70..416b53ed84b7 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -1,10 +1,5 @@ import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from './agent-session-background-task-child-views-capability' import { AGENT_SESSION_STOP_RUNTIME_CAPABILITIES } from './agent-session-stop-capabilities' -import { - AGENT_SESSION_SEND_CLIENT_CAPABILITIES, - AGENT_SESSION_SEND_HOST_CAPABILITIES -} from './agent-session-send-capabilities' -export * from './agent-session-send-capabilities' import { REMOTE_SERVER_UPDATE_CAPABILITY } from './remote-server-update' import { SKILL_BUNDLE_INSTALL_CAPABILITY, @@ -169,6 +164,16 @@ export const AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY = // receive their journal or drive their lifecycle. Mobile may receive a metadata-only placeholder; // the host still refuses agentSession.* methods and destructive tab mutations without capability. export const STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY = 'agent-session.structured.v1' as const +// Why: older structured clients render durable pending replies as uncertain delivery. Capable +// clients skip the host's bounded best-effort settlement observation. +export const AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY = + 'agent-session.pending-send-result.v1' as const +// Why: a send is now answered once the host accepts it, before any agent has it. A client without +// this cannot show a message rejected after that answer, so the host holds its reply until the +// message is handed over or rejected. Transitional: drop the hold once no supported desktop or +// mobile client lacks the capability; mobile must first show a rejected message in place. +export const AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY = + 'agent-session.accepted-send.v1' as const // Why: `agentSession.send`'s params are strict, so an older host rejects `delivery`; and only a // capable client can render the `queued` result arm, the draft list, and returned cards. DARK ON // PURPOSE — not in RUNTIME_CAPABILITIES: advertising still requires the integrated Codex steer @@ -327,7 +332,8 @@ export const NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ // host still requires the separate authenticated browser-client lease. export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ ...NATIVE_REMOTE_RUNTIME_CLIENT_CAPABILITIES, - ...AGENT_SESSION_SEND_CLIENT_CAPABILITIES, + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, // Why: only the renderer runs the retirement-proof ledger; CLI and mobile must keep full lists. @@ -401,9 +407,10 @@ export const RUNTIME_CAPABILITIES = [ AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY, AGENT_SESSION_KEYBOARD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY, + AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, // The host side: it accepts a send before any agent has it, and a Stop with no writer before a // turn starts, so a client may gate on either. - ...AGENT_SESSION_SEND_HOST_CAPABILITIES, + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, diff --git a/tests/e2e/cross-version-wire/accepted-send-hold-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/accepted-send-hold-downgrade.unit.test.ts deleted file mode 100644 index 8f1ef4772089..000000000000 --- a/tests/e2e/cross-version-wire/accepted-send-hold-downgrade.unit.test.ts +++ /dev/null @@ -1,346 +0,0 @@ -// A released desktop (accepted sends, no final-state reads) sending a chat's first message to this -// host. That client reads `pending` as delivered, and the message now starts its agent after the -// host accepts it, so the host holds its reply past the start, up to a cap, and answers a start -// still under way as `unknown`. Read with the released client's own outbox code: it keeps the -// message under the same id, resends only that id, and the host answers it from its ledger. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { - AgentSessionPreSpawnError, - type StructuredAgentSessionAdapter -} from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter' -import { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host' -import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' -import { createStructuredAgentSessionLogger } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-logger' -import { openTestJournalHostDatabase } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' -import type { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store-test-harness' -import { ACCEPTED_SEND_CLIENT_HOLD_MS } from '../../../src/main/runtime/rpc/methods/structured-agent-session-send-compatibility' -import { - AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, - AGENT_SESSION_SEND_FINAL_STATE_RUNTIME_CAPABILITY, - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY -} from '../../../src/shared/protocol-version' -import { - importReleaseCheckoutModule, - materializeReleaseCheckout, - resolveBaselineReleaseRef, - type ReleaseCheckout -} from './release-checkout' -import { - attachParams, - createIntentParams, - NOW, - resetOperationIds, - SESSION, - THREAD -} from './structured-agent-session-surface-manifest' -import { - loadAgentSessionWireBuild, - WORKING_TREE, - type AgentSessionWireBuild, - type RpcReply -} from './versioned-agent-session-wire' - -// Why: a cold CI run extracts the baseline checkout before the first pairing. -const SUITE_TIMEOUT_MS = 180_000 - -type OutboxEntry = { clientMessageId: string; state: string } -type ReleasedClient = { - capabilities: string[] - createEntry: (args: Record) => OutboxEntry - sendRequest: (entry: OutboxEntry, fence: number) => unknown - dispose: (input: Record) => { entries: OutboxEntry[] } - reconcile: (entries: OutboxEntry[], submissions: unknown[]) => OutboxEntry[] - admit: (entries: OutboxEntry[]) => { state: string } -} - -let current: AgentSessionWireBuild -let released: ReleasedClient - -async function releasedClient(checkout: ReleaseCheckout): Promise { - const [protocol, outbox, disposition] = await Promise.all([ - importReleaseCheckoutModule(checkout, '/src/shared/protocol-version.ts'), - importReleaseCheckoutModule(checkout, '/src/shared/structured-agent-session-outbox.ts'), - importReleaseCheckoutModule( - checkout, - '/src/shared/structured-agent-session-send-disposition.ts' - ) - ]) - return { - // What a released desktop advertises to a remote host, minus the capability this host keys - // the hold on, so the pairing stays exercised once a release ships it. No release advertises - // structured chats to a remote host yet (the host refuses them as clientCapabilityMissing), - // so it is added: the desktop this hold is for is one that does, without final-state reads. - capabilities: [ - ...(protocol.ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES as string[]).filter( - (capability) => capability !== AGENT_SESSION_SEND_FINAL_STATE_RUNTIME_CAPABILITY - ), - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY - ], - createEntry: outbox.createStructuredAgentSessionOutboxEntry as ReleasedClient['createEntry'], - sendRequest: outbox.structuredAgentSessionSendRequest as ReleasedClient['sendRequest'], - dispose: disposition.disposeStructuredAgentSessionSendResult as ReleasedClient['dispose'], - reconcile: outbox.reconcileStructuredAgentSessionOutbox as ReleasedClient['reconcile'], - admit: outbox.admitStructuredAgentSessionOutboxEntry as ReleasedClient['admit'] - } -} - -beforeAll(async () => { - current = await loadAgentSessionWireBuild(WORKING_TREE) - released = await releasedClient(await materializeReleaseCheckout(resolveBaselineReleaseRef())) -}, SUITE_TIMEOUT_MS) - -describe("a released desktop's first message to a chat at rest", () => { - let root: string - let store: AgentSessionRecordStore - let host: StructuredAgentSessionHost - /** Holds the agent's start open: the start is under way until it opens. */ - let open: () => void - - /** The first start is refused for an account switch, which books another try in 15 s. */ - let switching = false - - function adapter(): StructuredAgentSessionAdapter { - let gate = new Promise((resolve) => (open = resolve)) - return { - supportsCreate: () => true, - acquire: async ({ fence }) => { - if (switching) { - switching = false - throw new AgentSessionPreSpawnError(new Error('switching'), { - reason: 'accountSwitchInProgress' - }) - } - await gate - gate = Promise.resolve() - return { - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: store.getRecord(SESSION)?.lease.reservedSpawnToken ?? 'spawn-a' - }, - link: { - linkId: `link-${fence}`, - handle: { provider: 'codex', threadId: THREAD }, - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - } - } - }, - dispatch: async () => ({ - state: 'accepted', - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 1 } - }), - cancelTurn: async () => ({ cancelled: true }), - answerPrompt: async () => undefined, - setOption: async () => undefined - } - } - - function runtime(): unknown { - return { - getRuntimeId: () => 'runtime-1', - getClientSettings: () => ({ experimentalStructuredNativeChat: true }), - ensureStructuredAgentSessionHost: async () => undefined, - getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), - resolveStructuredAgentSessionCreateIntent: async () => { - const { - envelope: _envelope, - providerHandle: _providerHandle, - ...resolved - } = attachParams(null) - return resolved - }, - publishStructuredAgentSessionTab: () => {} - } - } - - async function call( - method: string, - params: unknown, - capabilities: readonly string[] - ): Promise { - const replies: RpcReply[] = [] - await current - .createDispatcher(runtime()) - .dispatchStreaming( - { id: `request-${method}`, authToken: 'cross-version-token', method, params }, - (raw) => replies.push(JSON.parse(raw) as RpcReply), - { clientKind: 'runtime', clientCapabilities: capabilities, clientId: 'paired-desktop' } - ) - return replies[0]! - } - - async function journalRows() { - return (await host.journalSnapshot(SESSION)).submissions - } - - beforeEach(async () => { - switching = false - resetOperationIds() - root = await mkdtemp(join(tmpdir(), 'orca-cross-version-accepted-send-')) - store = await openTestAgentSessionRecordStore(root) - host = new StructuredAgentSessionHost({ - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-a', - now: () => NOW - }) - setStructuredAgentSessionHost(host) - }) - - afterEach(async () => { - vi.useRealTimers() - setStructuredAgentSessionHost(null) - await rm(root, { recursive: true, force: true }) - }) - - it('is answered unknown at the cap while its start runs, and only ever resent under its own id', async () => { - expect(released.capabilities).toContain(AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY) - const created = await call('agentSession.create', createIntentParams(), [ - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY - ]) - const { fence } = created.result as { fence: number } - const entry = released.createEntry({ - clientMessageId: `${NOW}-${'a'.repeat(32)}`, - sessionId: SESSION, - text: 'review my notes', - attachments: [], - queuedAt: NOW - }) - - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) - let reply: RpcReply | undefined - void call('agentSession.send', released.sendRequest(entry, fence), released.capabilities).then( - (answered) => (reply = answered) - ) - await vi.advanceTimersByTimeAsync(ACCEPTED_SEND_CLIENT_HOLD_MS - 1) - // The start is still under way: a `pending` now would read as delivered. - expect(reply).toBeUndefined() - await vi.advanceTimersByTimeAsync(1) - vi.useRealTimers() - expect(reply).toMatchObject({ - ok: true, - result: { - value: { clientMessageId: entry.clientMessageId, submission: { dispatchState: 'unknown' } } - } - }) - - // The released client keeps it as unconfirmed under the same id, holding the queue: no new id. - const disposed = released.dispose({ - entries: [{ ...entry, state: 'dispatching' }], - entry: { ...entry, state: 'dispatching' }, - blockedClientMessageId: null, - result: reply!.result, - createOperationId: () => 'a-new-id-the-client-must-not-use' - }).entries - expect(disposed).toEqual([ - expect.objectContaining({ clientMessageId: entry.clientMessageId, state: 'unconfirmed' }) - ]) - expect(released.admit(disposed).state).toBe('blocked') - // The journal's own row moves it back to waiting on the host, still under the same id. - const waiting = released.reconcile(disposed, await journalRows()) - expect(waiting).toEqual([ - expect.objectContaining({ clientMessageId: entry.clientMessageId, state: 'dispatching' }) - ]) - - // A resend (its probe or its Retry) reuses the id; the host answers it from its ledger. - const resent = call( - 'agentSession.send', - released.sendRequest(entry, fence), - released.capabilities - ) - open() - expect(await resent).toMatchObject({ - ok: true, - result: { value: { clientMessageId: entry.clientMessageId } } - }) - await vi.waitFor(async () => - expect(await journalRows()).toEqual([ - expect.objectContaining({ - clientMessageId: entry.clientMessageId, - dispatchState: 'accepted' - }) - ]) - ) - expect(released.reconcile(waiting, await journalRows())).toEqual([]) - }) - - async function createdChat(): Promise { - const created = await call('agentSession.create', createIntentParams(), [ - STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY - ]) - return (created.result as { fence: number }).fence - } - - function firstMessage(hex: string): OutboxEntry { - return released.createEntry({ - clientMessageId: `${NOW}-${hex.repeat(32)}`, - sessionId: SESSION, - text: 'review my notes', - attachments: [], - queuedAt: NOW - }) - } - - it('is answered once its start hands it over, within the cap', async () => { - const fence = await createdChat() - const entry = firstMessage('b') - - const reply = call( - 'agentSession.send', - released.sendRequest(entry, fence), - released.capabilities - ) - open() - expect(await reply).toMatchObject({ - ok: true, - result: { value: { submission: { handedOverAt: expect.any(Number) } } } - }) - }) - - it('waits out a start that failed and booked another try, rather than answer it pending', async () => { - const fence = await createdChat() - switching = true - open() - const entry = firstMessage('d') - - vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }) - let reply: RpcReply | undefined - void call('agentSession.send', released.sendRequest(entry, fence), released.capabilities).then( - (answered) => (reply = answered) - ) - await vi.advanceTimersByTimeAsync(ACCEPTED_SEND_CLIENT_HOLD_MS - 1) - expect(reply).toBeUndefined() - await vi.advanceTimersByTimeAsync(1) - vi.useRealTimers() - expect(reply).toMatchObject({ - ok: true, - result: { value: { submission: { dispatchState: 'unknown' } } } - }) - }) - - it('answers a current desktop at acceptance, while the start still runs', async () => { - const fence = await createdChat() - const entry = firstMessage('c') - - const reply = await call('agentSession.send', released.sendRequest(entry, fence), [ - ...released.capabilities, - AGENT_SESSION_SEND_FINAL_STATE_RUNTIME_CAPABILITY - ]) - expect(reply).toMatchObject({ - ok: true, - result: { value: { submission: { dispatchState: 'pending' } } } - }) - open() - }) -}) diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index a7fcab5902b5..f3a78d8547a5 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -29,7 +29,6 @@ import { AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, AGENT_SESSION_REWIND_RUNTIME_CAPABILITY, - AGENT_SESSION_SEND_FINAL_STATE_RUNTIME_CAPABILITY, AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY, AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY @@ -853,12 +852,9 @@ describe('cross-version structured agent sessions', () => { const restarted = await bootHost('c') startGate = new Promise((resolve) => (open = resolve)) - // Current: it follows the message to its final state itself (a desktop with only accepted - // sends is held past the start: accepted-send-hold-downgrade.unit.test.ts). const currentReply = await call('agentSession.send', sendParams('current', created.fence), [ ...released, - AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, - AGENT_SESSION_SEND_FINAL_STATE_RUNTIME_CAPABILITY + AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY ]) expect(currentReply[0]).toMatchObject({ ok: true, From 743f57da089dea332c1ae2da6737be4b53bed682 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:14:22 -0700 Subject: [PATCH 55/84] test(native-chat): main's Stop-event and preamble tests write at the started chat's fence and read the preamble's delivery --- .../structured-agent-session-rewind.test.ts | 4 +++- ...d-agent-session-stop-event-binding.test.ts | 4 ++-- ...d-agent-session-stop-event-entries.test.ts | 15 +++++++++----- ...d-agent-session-stop-event-restart.test.ts | 20 ++++++++++++------- .../orchestration/agent-facing-parity.test.ts | 1 + 5 files changed, 29 insertions(+), 15 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index a7671573ca1d..c45081856966 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -110,7 +110,9 @@ afterEach(async () => { /** An interrupted rewind recovers when the agent next starts: here, after the host puts it to rest. */ async function restartAgent() { - await host.collaboratorsForTests().lifetime.stopAgent(HOST_TEST_SESSION, { cause: 'evict', resting: true }) + await host + .collaboratorsForTests() + .lifetime.stopAgent(HOST_TEST_SESSION, { cause: 'evict', resting: true }) return startAgentForTests(host, HOST_TEST_SESSION) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts index 2654b20ac730..b65132450417 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-binding.test.ts @@ -262,7 +262,7 @@ describe('a rewind that restates a turnless Stop', () => { expect(await rig.stop()).toMatchObject({ ok: true }) expect(journal().stopMarks.latest()?.event).not.toHaveProperty('turnId') await rig.settleAccepted(stopped, 'stopped') - const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + const scope = { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } const stoppedTurn = { ...LATER_TURN, turnId: 'turn-stopped', ordinal: 998 } const ended = { kind: 'turn' as const, @@ -277,7 +277,7 @@ describe('a rewind that restates a turnless Stop', () => { ) await journal().appendItem(stoppedTurn, { ...ended, completedAt: Date.now() + 1 }, scope) - await journal().replaceEpochItems('handle_forked', 1, [ + await journal().replaceEpochItems('handle_forked', fence(), [ { identity: stoppedTurn, body: { ...ended, completedAt: Date.now() + 1, outcome: 'cancellation' } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts index dd0acf82b371..7dcb1d8d4fab 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-entries.test.ts @@ -22,6 +22,11 @@ afterEach(() => rig.dispose()) /** Swept only when a test ticks it. */ const MANUAL_IDLE_SWEEP = { idleMs: 0, intervalMs: 60 * 60 * 1000 } +/** The started chat's fence: a create at rest holds the first, and its first start takes the next. */ +function fence(): number { + return rig.store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? 1 +} + function journal() { const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal if (!open) { @@ -56,7 +61,7 @@ async function runningTurn(turnId = 'turn-1'): Promise { await journal().appendItem( { provider: 'codex', threadId: 'thread-1', turnId, ordinal: 999 }, { kind: 'turn', turnId, state: 'running', startedAt: 1 }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) return working } @@ -211,7 +216,7 @@ describe('every Stop entry writes its event, with its reason, before it ends the turnId: 'turn-1', ordinal: 999 } - const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + const scope = { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } await journal().appendItem( identity, { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 1, userItemId: opener }, @@ -271,7 +276,7 @@ describe('every Stop entry writes its event, with its reason, before it ends the await journal().appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 999 }, { kind: 'turn', turnId: 'turn-1', state: 'interrupted', completedAt: Date.now() }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) expect(journal().activeTurnId()).toBeNull() const sink = rig.host['runtimeState'].eventSinkFor(HOST_TEST_SESSION) @@ -368,7 +373,7 @@ describe("a person's Stop pause and the Stop events after it", () => { 'starting' ) // A person's Stop of an earlier turn still pauses the queue. - await journal().appendStopEvent({ reason: 'user-stop', caller: 'client-1' }, 1) + await journal().appendStopEvent({ reason: 'user-stop', caller: 'client-1' }, fence()) expect(journal().queuedMessages.userStopInForce()).not.toBeNull() const atClose = stopEventsAtClose() @@ -390,7 +395,7 @@ describe("a person's Stop pause and the Stop events after it", () => { await journal().appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'turn-mail', ordinal: 999 }, { kind: 'turn', turnId: 'turn-mail', state: 'running', startedAt: 1 }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) const atClose = stopEventsAtClose() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts index 4ae9f59a015a..7e2039a24fc1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-event-restart.test.ts @@ -47,6 +47,11 @@ let rig: QueuedMessageTestRig afterEach(() => rig.dispose()) +/** The started chat's fence: a create at rest holds the first, and its first start takes the next. */ +function fence(): number { + return rig.store.getRecord(HOST_TEST_SESSION)?.lease.runtimeFence ?? 1 +} + function journal() { const open = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal if (!open) { @@ -65,7 +70,7 @@ async function runningTurn( await journal().appendItem( identity, { kind: 'turn', turnId: TURN, state: 'running', startedAt: Date.now() - 30_000 }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } @@ -74,6 +79,7 @@ async function runningTurn( async function restartAndSettle( proof: 'pid-absent' | 'exit-observed' | 'unproven' = 'pid-absent' ): Promise { + const ownerFence = fence() rig.crashRestartHostProcess() await rig.host.journalSnapshot(HOST_TEST_SESSION) const now = Date.now() @@ -84,13 +90,13 @@ async function restartAndSettle( kind: proof, detail: 'the relaunch proved the old child gone', observedAt: now + 60_000, - ownerFence: 1, + ownerFence, lastProvenAliveAt: now - 20_000 } await settleStaleStructuredAgentSessionState({ journal: journal(), sessionId: HOST_TEST_SESSION, - fence: 2, + fence: ownerFence + 1, acquisitionGeneration: 'generation-2', deathEvidence }) @@ -218,12 +224,12 @@ describe('a restart between a Stop and its turn end', () => { outcome: 'success', completedAt: Date.now() }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal().appendItem( CODEX_NEXT_TURN, { kind: 'turn', turnId: NEXT_TURN, state: 'running', startedAt: Date.now() - 10_000 }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) // Its exit is proven after the Stop, so only the turn the Stop named decides. @@ -262,7 +268,7 @@ describe('a restart between a Stop and its turn end', () => { await journal().appendItem( CODEX_TURN, { kind: 'turn', turnId: TURN, state: 'running', startedAt: Date.now() - 30_000 }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) // Its exit is proven after the Stop, so only whose turn it is decides. @@ -279,7 +285,7 @@ describe('a restart between a Stop and its turn end', () => { expect(await rig.stop()).toMatchObject({ ok: true }) expect(journal().stopMarks.latest()?.event.turnId).toBeUndefined() await rig.settleAccepted(stopped, 'stopped') - const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + const scope = { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } // Codex names the send that opened a turn on its row. const opener = agentJournalSubmissionKey(stopped) await journal().appendItem( diff --git a/src/main/runtime/orchestration/agent-facing-parity.test.ts b/src/main/runtime/orchestration/agent-facing-parity.test.ts index f682dfc7ce28..4439c71a37f9 100644 --- a/src/main/runtime/orchestration/agent-facing-parity.test.ts +++ b/src/main/runtime/orchestration/agent-facing-parity.test.ts @@ -30,6 +30,7 @@ const sent = vi.hoisted((): { preambles: string[] } => ({ preambles: [] })) vi.mock('../rpc/methods/orchestration-structured-worker-session', () => ({ sendStructuredWorkerPreamble: async (args: { preamble: string }) => { sent.preambles.push(args.preamble) + return { state: 'accepted' as const } } })) From 059444df219ee6c95f6ec329286cebc6e749e562 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:19:29 -0700 Subject: [PATCH 56/84] revert(native-chat): a launch prompt counts as delivered once the host records it, as before this PR Undoes the interim that made every launch-prompt caller wait for the agent to take the prompt: - callers (notes, annotations, fix checks, recovery, work items, QuickLaunch) are told at record again; - a launch prompt rejected for good keeps the chat's own Retry; Stop restores it to the composer; - the phone's agent.launch answers journaled at record again, with no 60 s wait or unknown refusal; - copy back to "Install it, then retry."; the "Send it again from where you started it" and "didn't take the prompt" strings are gone. Only the GitHub/GitLab review reply will wait for the agent, derived from the message (next commits). Files are back to #24340's head (873afe7c549) where only the interim had changed them. --- .../agent-launch-executor.test.ts | 34 +-- .../agent-launch/agent-launch-executor.ts | 8 +- .../agent-launch-prompt-delivery.ts | 26 +- .../agent-launch-surface-factories.ts | 18 +- ...tructured-agent-session-send-settlement.ts | 9 +- ...ent-launch-structured-prompt-start.test.ts | 133 ---------- .../agent-launch-structured-prompt.test.ts | 170 +------------ .../methods/agent-launch-structured-prompt.ts | 58 ----- .../rpc/methods/agent-launch-surfaces.ts | 4 +- .../use-browser-page-annotation-send.test.tsx | 64 ----- .../components/editor/NotesSendMenu.test.tsx | 84 ------ .../agent-session-failure-words-text.ts | 4 +- ...red-agent-session-delivery-notices.test.ts | 63 ----- ...ructured-agent-session-delivery-notices.ts | 25 +- ...structured-agent-session-outbox-storage.ts | 12 +- ...session-withdrawn-message-restore.test.tsx | 75 +----- ...agent-session-withdrawn-message-restore.ts | 3 +- ...ed-agent-session-outbox-admission.test.tsx | 17 +- .../pull-request-page/checks/fix-launch.ts | 2 - ...omment-resolution-launch-delivery.test.tsx | 239 ------------------ .../runSourceControlAgentActionStart.ts | 4 +- .../source-control/ai/recovery-launch.ts | 8 +- .../tab-bar/QuickLaunchButton.test.ts | 21 +- .../components/tab-bar/QuickLaunchButton.tsx | 22 -- .../src/i18n/en-runtime-required.json | 2 +- src/renderer/src/i18n/locales/en.json | 5 +- src/renderer/src/i18n/locales/es.json | 5 +- src/renderer/src/i18n/locales/fr.json | 5 +- src/renderer/src/i18n/locales/ja.json | 5 +- src/renderer/src/i18n/locales/ko.json | 5 +- src/renderer/src/i18n/locales/zh.json | 5 +- .../src/lib/agent-session-launch-plan.test.ts | 20 -- .../src/lib/agent-session-launch-plan.ts | 13 +- .../src/lib/fix-checks-agent-launch.test.ts | 25 -- .../src/lib/fix-checks-agent-launch.ts | 6 +- .../src/lib/launch-agent-in-new-tab.ts | 8 +- .../launch-agent-session-continuation.test.ts | 4 +- .../lib/launch-agent-session-continuation.ts | 2 - .../src/lib/launch-prompt-delivered.test.ts | 20 -- .../src/lib/launch-prompt-delivered.ts | 17 -- .../launch-work-item-direct-agent-routing.ts | 5 +- ...unch-work-item-direct-route-preparation.ts | 5 +- .../src/lib/launch-work-item-direct-types.ts | 3 - .../src/lib/launch-work-item-direct.test.ts | 41 --- .../src/lib/launch-work-item-direct.ts | 10 +- ...structured-agent-session-launch-callers.ts | 16 -- ...agent-session-launch-join-delivery.test.ts | 32 --- ...ed-agent-session-launch-prompt-handover.ts | 92 ------- ...gent-session-launch-prompt-test-support.ts | 150 ----------- ...ctured-agent-session-launch-prompt.test.ts | 170 +++---------- .../structured-agent-session-launch-prompt.ts | 46 +--- .../lib/structured-agent-session-launch.ts | 14 +- src/shared/agent-session-failure-copy.ts | 2 +- src/shared/agent-session-failure-words.ts | 2 +- .../structured-agent-session-outbox-source.ts | 19 -- ...ed-agent-session-outbox-stop-withdrawal.ts | 19 +- src/shared/structured-agent-session-outbox.ts | 13 +- 57 files changed, 118 insertions(+), 1771 deletions(-) delete mode 100644 src/main/runtime/rpc/methods/agent-launch-structured-prompt-start.test.ts delete mode 100644 src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx delete mode 100644 src/renderer/src/lib/launch-prompt-delivered.test.ts delete mode 100644 src/renderer/src/lib/launch-prompt-delivered.ts delete mode 100644 src/renderer/src/lib/structured-agent-session-launch-prompt-handover.ts delete mode 100644 src/renderer/src/lib/structured-agent-session-launch-prompt-test-support.ts delete mode 100644 src/shared/structured-agent-session-outbox-source.ts diff --git a/src/main/agent-launch/agent-launch-executor.test.ts b/src/main/agent-launch/agent-launch-executor.test.ts index f48d73bee75d..e7270287fb53 100644 --- a/src/main/agent-launch/agent-launch-executor.test.ts +++ b/src/main/agent-launch/agent-launch-executor.test.ts @@ -10,10 +10,7 @@ import { describe, expect, it, vi } from 'vitest' import { executeAgentLaunch, type AgentLaunchExecution } from './agent-launch-executor' -import { - AgentLaunchStructuredSessionRefusedError, - type StructuredLaunchPromptDelivery -} from './agent-launch-surface-factories' +import { AgentLaunchStructuredSessionRefusedError } from './agent-launch-surface-factories' import type { AgentLaunchIntent } from '../../shared/agent-launch-intent' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' @@ -29,7 +26,6 @@ function harness(options: { createSupportThrows?: boolean structuredCreateError?: Error deliveredMessageId?: string | null - notTakenWarning?: string terminalPromptDelivered?: boolean }) { const calls: string[] = [] @@ -64,15 +60,9 @@ function harness(options: { calls.push('createTerminalAgent') return { handle: 'term_1' } }) - const deliverStructuredPrompt = vi.fn(async (): Promise => { + const deliverStructuredPrompt = vi.fn(async () => { calls.push('deliverStructuredPrompt') - if (options.deliveredMessageId === null) { - return { - taken: false, - ...(options.notTakenWarning ? { warning: options.notTakenWarning } : {}) - } - } - return { taken: true, messageId: options.deliveredMessageId ?? 'msg-1' } + return options.deliveredMessageId === undefined ? 'msg-1' : options.deliveredMessageId }) const deliverTerminalPrompt = vi.fn(async () => { calls.push('deliverTerminalPrompt') @@ -290,24 +280,6 @@ describe('the prompt receipt', () => { expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'not-delivered' }) }) - it('fails the launch as unknown when its prompt is still waiting on a retried start', async () => { - const h = harness({}) - const stillStarting = Object.assign(new Error('agent_session_operation_unknown'), { - code: 'agent_session_operation_unknown' - }) - h.deliverStructuredPrompt.mockRejectedValueOnce(stillStarting) - - await expect(h.run({ ...CREATE_INTENT, prompt: SUBMIT })).rejects.toBe(stillStarting) - }) - - it("reports the host's words for a prompt the agent did not take as the launch's warning", async () => { - const h = harness({ deliveredMessageId: null, notTakenWarning: 'The agent is still starting.' }) - const result = await h.run({ ...CREATE_INTENT, prompt: SUBMIT }) - - expect(result.prompt).toEqual({ delivery: 'submit', outcome: 'not-delivered' }) - expect(result.warning).toBe('The agent is still starting.') - }) - it('leaves a draft with the caller, because the host has no composer to hold one', async () => { const h = harness({}) const result = await h.run({ diff --git a/src/main/agent-launch/agent-launch-executor.ts b/src/main/agent-launch/agent-launch-executor.ts index 2d593234251b..1d9bae4f45d3 100644 --- a/src/main/agent-launch/agent-launch-executor.ts +++ b/src/main/agent-launch/agent-launch-executor.ts @@ -164,17 +164,13 @@ export async function executeAgentLaunch( // stale once the launch recovers by building a working one, so the user can be told the agent did // not start while looking at it. Telling those apart needs `createManagedWorktree` to stop // multiplexing "couldn't copy untracked files" and "startup terminal failed" into one string. - const prompt = await settleLaunchPromptDisposal(execution, created) - const warning = combineLaunchWarnings( - combineLaunchWarnings(placed.warning, created.warning), - prompt.warning - ) + const warning = combineLaunchWarnings(placed.warning, created.warning) return { outcome: created.outcome, worktreeId: placed.worktreeId, receipt: settled, ...(warning ? { warning } : {}), - ...promptReceipt(intent, prompt.disposal) + ...promptReceipt(intent, await settleLaunchPromptDisposal(execution, created)) } } diff --git a/src/main/agent-launch/agent-launch-prompt-delivery.ts b/src/main/agent-launch/agent-launch-prompt-delivery.ts index e1a4cb36f198..e77e0cabbe56 100644 --- a/src/main/agent-launch/agent-launch-prompt-delivery.ts +++ b/src/main/agent-launch/agent-launch-prompt-delivery.ts @@ -25,31 +25,25 @@ import type { import { agentPromptRidesLaunchCommand } from '../../shared/tui-agent-startup' import type { AgentLaunchModeReceipt } from './agent-launch-mode' import type { AgentLaunchExecution, CreatedSurface } from './agent-launch-executor' -import type { - AgentLaunchStructuredSurface, - StructuredLaunchPromptDelivery -} from './agent-launch-surface-factories' +import type { AgentLaunchStructuredSurface } from './agent-launch-surface-factories' export const HANDED_TO_TERMINAL: AgentLaunchPromptDisposal = { outcome: 'handed-to-terminal' } const NOT_DELIVERED: AgentLaunchPromptDisposal = { outcome: 'not-delivered' } -/** Each surface delivers its own way, so the disposal is decided where the surface is known. A - * structured prompt the agent did not take can carry the host's words for why, as a launch warning. */ +/** Each surface delivers its own way, so the disposal is decided where the surface is known. */ export async function settleLaunchPromptDisposal( execution: AgentLaunchExecution, created: CreatedSurface -): Promise<{ disposal: AgentLaunchPromptDisposal; warning?: string }> { +): Promise { if (created.structured) { - const delivered = await deliverStructuredLaunchPrompt(execution, created.structured) - return delivered.taken - ? { disposal: { outcome: 'journaled', messageId: delivered.messageId } } - : { disposal: NOT_DELIVERED, ...(delivered.warning ? { warning: delivered.warning } : {}) } + const messageId = await deliverStructuredLaunchPrompt(execution, created.structured) + return messageId ? { outcome: 'journaled', messageId } : NOT_DELIVERED } // The startup command already carries an argv agent's prompt; there is nothing left to write. if (created.promptRodeLaunchCommand) { - return { disposal: HANDED_TO_TERMINAL } + return HANDED_TO_TERMINAL } - return { disposal: await deliverTerminalLaunchPrompt(execution, created.outcome.handle) } + return deliverTerminalLaunchPrompt(execution, created.outcome.handle) } /** @@ -60,17 +54,17 @@ export async function settleLaunchPromptDisposal( async function deliverStructuredLaunchPrompt( execution: AgentLaunchExecution, structured: AgentLaunchStructuredSurface -): Promise { +): Promise { const { intent, surfaces } = execution if (!intent.prompt || intent.prompt.delivery !== 'submit') { - return { taken: false } + return null } return ( (await surfaces.deliverStructuredPrompt?.({ sessionId: structured.sessionId, fence: structured.fence, prompt: intent.prompt - })) ?? { taken: false } + })) ?? null ) } diff --git a/src/main/agent-launch/agent-launch-surface-factories.ts b/src/main/agent-launch/agent-launch-surface-factories.ts index fe2198ea8a9f..867eac40ab61 100644 --- a/src/main/agent-launch/agent-launch-surface-factories.ts +++ b/src/main/agent-launch/agent-launch-surface-factories.ts @@ -10,11 +10,6 @@ import type { TuiAgent } from '../../shared/tui-agent' /** How a surface is built once the executor has decided which one. Injected because an * orchestration worker's session carries a redrive subscription and a mailbox a plain launch * must not take, while the decision and ordering above it are identical. */ -/** What became of a structured launch prompt; see `deliverStructuredPrompt`. */ -export type StructuredLaunchPromptDelivery = - | { taken: true; messageId: string } - | { taken: false; warning?: string } - export type AgentLaunchSurfaceFactory = { createStructuredSession(args: { worktreeId: string @@ -47,20 +42,17 @@ export type AgentLaunchSurfaceFactory = { warning?: string }> /** - * Sends the launch text as the session's first message, which starts its agent, and answers - * whether the agent took it (with the transcript row's id) or not (with the host's words for why - * in `warning`, when it has them). + * Commits the launch text as the session's first turn, answering with the transcript row's id. * - * Not taken is the answer for every failure — a refused send, an unreachable host, a throw, a start - * that failed — since the caller keeps the text under `not-delivered` but cannot un-create a - * workspace. A start still being retried when the wait ends is the exception: it throws an unknown - * outcome, because the text will still be sent and "not delivered" would invite a second copy. + * `null` means nothing was committed, and is the answer for every failure — a refused send, an + * unreachable host, a throw. Delivery must not fail a launch whose agent is already running: the + * caller can resend under `not-delivered`, but it cannot un-create a workspace. */ deliverStructuredPrompt?(args: { sessionId: string fence: number prompt: AgentLaunchPrompt - }): Promise + }): Promise /** * Writes the launch text into a terminal agent's live PTY, answering whether it landed. * diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts index 48d3f5840095..2a83ee6df56d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-settlement.ts @@ -21,14 +21,12 @@ type SendSettlement = SettledSend | 'pending' | 'missing' /** What ends a wait: the provider's answer, the host handing the message over, or either that or * the message waiting behind a running command, which hands nothing over until it ends. A failed * start recorded on the message ends each: the host answers it now, while it waits for its next try. - * `verdict` alone waits through those tries, for the message's own answer, and `final` through a - * lost answer (`unknown`) too, which a late echo can still prove accepted. */ + * `verdict` alone waits through those tries, for the message's own answer. */ export type SendSettlementPoint = | 'answered' | 'handed-over' | 'handed-over-or-behind-command' | 'verdict' - | 'final' export type SendSettlementWaitOptions = { signal?: AbortSignal @@ -66,9 +64,8 @@ function settledSend( return 'missing' } const waiting = - until === 'verdict' || until === 'final' - ? submission.dispatchState === 'pending' || - (until === 'final' && submission.dispatchState === 'unknown') + until === 'verdict' + ? submission.dispatchState === 'pending' : !isRetryingStructuredAgentSessionStart(submission) && (until === 'answered' ? submission.dispatchState === 'pending' diff --git a/src/main/runtime/rpc/methods/agent-launch-structured-prompt-start.test.ts b/src/main/runtime/rpc/methods/agent-launch-structured-prompt-start.test.ts deleted file mode 100644 index e4fe44a5f957..000000000000 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt-start.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -// A launch prompt is the new chat's first message, which starts its agent. Read from a real host: -// a start still being retried at the budget refuses the launch as unknown, and leaves the chat and -// its message to start on the next try. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' -import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' -import { - AgentSessionPreSpawnError, - type StructuredAgentSessionAdapter -} from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' -import { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' -import { - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD, - hostTestAttachParams -} from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' -import { mintAgentSessionOperationId } from '../../orchestration/structured-pointer-operation-id' -import { deliverStructuredAgentSessionLaunchPrompt } from './agent-launch-structured-prompt' -import { createStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger' - -const CALLER = { callerKey: 'client-1' } - -let root: string -let host: StructuredAgentSessionHost -let acquire: Mock -let closeSession: Mock> -let fence: number -let clock: number - -const accountSwitch = () => - new AgentSessionPreSpawnError(new Error('switching'), { reason: 'accountSwitchInProgress' }) - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-launch-prompt-start-')) - clock = Date.now() - const store = await openTestAgentSessionRecordStore(root) - acquire = vi.fn(async ({ fence: acquiredFence, spawnToken }) => ({ - process: { hostId: 'local', pid: 4242, processStartTimeMs: clock, spawnToken }, - link: { - linkId: `link-${acquiredFence}`, - handle: { provider: 'codex', threadId: THREAD }, - origin: 'created', - mintedAtFence: acquiredFence, - observedAt: clock - } - })) - closeSession = vi.fn(async () => true) - host = new StructuredAgentSessionHost({ - logger: createStructuredAgentSessionLogger(), - store, - adapter: { - supportsCreate: () => true, - acquire, - releaseAcquisition: vi.fn(async () => true), - closeSession, - dispatch: vi.fn(async ({ clientMessageId }) => ({ - state: 'accepted' as const, - providerIdentity: { - provider: 'codex' as const, - threadId: THREAD, - turnId: `turn-${clientMessageId}`, - ordinal: 1 - } - })), - cancelTurn: vi.fn(async () => ({ cancelled: true })), - answerPrompt: vi.fn(), - setOption: vi.fn() - }, - journalDatabase: openTestJournalHostDatabase(root), - claimKeyId: 'key-1', - now: () => clock - }) - const create = hostTestAttachParams(null) - create.envelope.clientOperationId = mintAgentSessionOperationId(clock) - const created = await host.create(CALLER, create) - if (!created.ok) { - throw new Error(created.refusal.message) - } - fence = created.fence -}) - -afterEach(async () => { - await host.flushAllStreamedEvents() - await rm(root, { recursive: true, force: true }) -}) - -function deliver(budgetMs: number) { - return deliverStructuredAgentSessionLaunchPrompt({ - host, - caller: CALLER, - sessionId: SESSION, - fence, - text: 'review my notes', - budgetMs - }) -} - -describe('a launch prompt whose agent is still starting at the budget', () => { - it('refuses the launch as unknown, and the chat keeps its message and starts it later', async () => { - acquire.mockRejectedValueOnce(accountSwitch()) - - await expect(deliver(200)).rejects.toMatchObject({ - refusal: { code: 'agent_session_operation_unknown' } - }) - - // The chat survives the refused launch: nothing closed it, and its first message still waits. - expect(closeSession).not.toHaveBeenCalled() - expect(host.sessionAgent(SESSION)).not.toBeNull() - const [waiting] = (await host.journalSnapshot(SESSION)).submissions - expect(waiting).toMatchObject({ dispatchState: 'pending', startRetry: { attempts: 1 } }) - - // The retry comes due; the delivery loop starts the agent with that same message. - clock += 15_000 - host.collaboratorsForTests().conversationDelivery.loop.wake(SESSION) - await vi.waitFor(async () => - expect((await host.journalSnapshot(SESSION)).submissions).toEqual([ - expect.objectContaining({ - clientMessageId: waiting.clientMessageId, - dispatchState: 'accepted' - }) - ]) - ) - expect(acquire).toHaveBeenCalledTimes(2) - }) - - it('is taken when the start succeeds within the budget', async () => { - await expect(deliver(5_000)).resolves.toMatchObject({ taken: true }) - }) -}) 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 index 913d70f6a30f..1b5910519361 100644 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.test.ts @@ -6,15 +6,8 @@ * that no failure mode can return an id, because an id is what the caller reads as "committed". */ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentJournalSubmission } from '../../../../shared/agent-session-journal-types' -import { StructuredAgentSessionSendSettlement } from '../../../native-chat/agent-session-wire/structured-agent-session-send-settlement' -import { - commitStructuredAgentSessionLaunchPrompt, - deliverStructuredAgentSessionLaunchPrompt, - STRUCTURED_LAUNCH_PROMPT_SETTLEMENT_BUDGET_MS, - STRUCTURED_LAUNCH_PROMPT_STILL_STARTING -} from './agent-launch-structured-prompt' +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-outbox' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' @@ -111,162 +104,3 @@ describe('committing a launch prompt', () => { expect(send).not.toHaveBeenCalled() }) }) - -/** A host whose first message the test moves along, read through the host's own settlement wait. */ -function settlingHost() { - let submission: AgentJournalSubmission | undefined - const journal = { - submissions: () => (submission ? [submission] : []), - cursor: () => ({ epoch: 'epoch-1', sequence: 2 }), - activeTurnId: () => null - } - const settlement = new StructuredAgentSessionSendSettlement(() => journal) - const send = vi.fn( - async (_caller: unknown, params: { envelope: { clientOperationId: string } }) => { - submission = { - clientMessageId: params.envelope.clientOperationId, - fence: 4, - payloadFingerprint: 'fingerprint', - dispatchState: 'pending', - providerItemId: null, - reason: null, - submittedAt: 1, - resolvedAt: null, - handoverRecorded: true - } - return { - ok: true as const, - value: { clientMessageId: submission.clientMessageId, submission } - } - } - ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub implements the only host methods the delivery reaches. - const host = { - send, - journalSnapshot: vi.fn(async () => ({ submissions: journal.submissions() })), - waitForSendSettlement: settlement.wait - } as unknown as StructuredAgentSessionHost - return { - host, - /** The host publishing the message's next state, as its journal commits do. */ - becomes: (change: Partial) => { - submission = { ...submission!, ...change } - settlement.publish('sess-1', journal) - } - } -} - -function deliver(host: StructuredAgentSessionHost) { - return deliverStructuredAgentSessionLaunchPrompt({ - host, - caller: CALLER, - sessionId: 'sess-1', - fence: 4, - text: 'review my notes' - }) -} - -describe('a launch prompt followed to whether its agent took it', () => { - afterEach(() => { - vi.useRealTimers() - }) - - it('names the row once the agent takes it', async () => { - const { host, becomes } = settlingHost() - const delivered = deliver(host) - await vi.waitFor(() => expect(host.send).toHaveBeenCalled()) - becomes({ dispatchState: 'accepted', resolvedAt: 2 }) - - await expect(delivered).resolves.toMatchObject({ taken: true, messageId: expect.any(String) }) - }) - - it("is not taken when the start fails for good, in the host's words", async () => { - const { host, becomes } = settlingHost() - const delivered = deliver(host) - await vi.waitFor(() => expect(host.send).toHaveBeenCalled()) - becomes({ - dispatchState: 'rejected', - reason: 'Claude is not signed in. Sign in, then send your message again.', - rejection: { kind: 'notSignedIn' } - }) - - await expect(delivered).resolves.toEqual({ - taken: false, - warning: 'Claude is not signed in. Sign in, then send your message again.' - }) - }) - - it('waits through a retried start, and at the budget refuses as an unknown outcome, never later', async () => { - vi.useFakeTimers() - const { host, becomes } = settlingHost() - let refused: unknown - let answered = false - void deliver(host).then( - () => { - answered = true - }, - (error: unknown) => { - refused = error - } - ) - await vi.advanceTimersByTimeAsync(0) - becomes({ - startRetry: { - attempts: 1, - reason: 'An account switch is in progress.', - rejection: { kind: 'accountSwitchInProgress' }, - failedAt: 1, - nextAttemptAt: 15_001 - } - }) - - await vi.advanceTimersByTimeAsync(STRUCTURED_LAUNCH_PROMPT_SETTLEMENT_BUDGET_MS - 1) - expect(refused).toBeUndefined() - await vi.advanceTimersByTimeAsync(1) - expect(answered).toBe(false) - expect(refused).toMatchObject({ - refusal: { - code: 'agent_session_operation_unknown', - message: STRUCTURED_LAUNCH_PROMPT_STILL_STARTING - } - }) - }) - - it('waits through a lost answer, and names the row once it turns accepted', async () => { - vi.useFakeTimers() - const { host, becomes } = settlingHost() - let answer: unknown - void deliver(host).then((value) => { - answer = value - }) - await vi.advanceTimersByTimeAsync(0) - becomes({ handedOverAt: 2, dispatchState: 'unknown' }) - await vi.advanceTimersByTimeAsync(1_000) - expect(answer).toBeUndefined() - - becomes({ dispatchState: 'accepted', resolvedAt: 3 }) - await vi.advanceTimersByTimeAsync(0) - expect(answer).toMatchObject({ taken: true, messageId: expect.any(String) }) - }) - - it('refuses as an unknown outcome when the answer is still lost at the budget', async () => { - vi.useFakeTimers() - const { host, becomes } = settlingHost() - let refused: unknown - let answered = false - void deliver(host).then( - () => { - answered = true - }, - (error: unknown) => { - refused = error - } - ) - await vi.advanceTimersByTimeAsync(0) - becomes({ handedOverAt: 2, dispatchState: 'unknown' }) - - await vi.advanceTimersByTimeAsync(STRUCTURED_LAUNCH_PROMPT_SETTLEMENT_BUDGET_MS) - expect(answered).toBe(false) - expect(refused).toMatchObject({ refusal: { code: 'agent_session_operation_unknown' } }) - }) -}) diff --git a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts index 8fb523398f3b..00c779fffe0b 100644 --- a/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts +++ b/src/main/runtime/rpc/methods/agent-launch-structured-prompt.ts @@ -24,9 +24,6 @@ import { createStructuredAgentSessionOperationId } from '../../../../shared/stru 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' -import type { StructuredLaunchPromptDelivery } from '../../../agent-launch/agent-launch-surface-factories' -import { agentSessionSendSubmission } from '../../../../shared/agent-session-wire' -import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' /** * The committed transcript row's id, or `null` when nothing was committed. @@ -83,58 +80,3 @@ export async function commitStructuredAgentSessionLaunchPrompt(args: { return null } } - -/** - * How long a launch waits for the agent to take its first message. A chat's first message starts - * its agent, so a caller told "delivered" would claim a start that may still fail; the phone's - * prompted launch gives up after 90 s, and the create before this is quick. - */ -export const STRUCTURED_LAUNCH_PROMPT_SETTLEMENT_BUDGET_MS = 60_000 - -/** Why a launch whose agent is still starting when the wait ends cannot say how it went. */ -export const STRUCTURED_LAUNCH_PROMPT_STILL_STARTING = - 'The agent is still starting; its prompt will be sent automatically once it starts.' - -/** - * The launch prompt sent as the chat's first message, followed to whether the agent took it. - * - * Temporary: an older phone reads any committed row as "Agent started" and marks its notes sent, - * so the host answers only once the start has settled, within the budget. A start still being - * retried at the budget refuses the launch as an unknown outcome: the phone keeps its notes and - * says it couldn't confirm the start, and the message is still sent when the agent starts. The - * follow-up is phones that watch the message's own settlement and finish then. - */ -export async function deliverStructuredAgentSessionLaunchPrompt( - args: Parameters[0] & { budgetMs?: number } -): Promise { - const messageId = await commitStructuredAgentSessionLaunchPrompt(args) - if (!messageId || !args.host) { - return { taken: false } - } - const settled = await args.host - .waitForSendSettlement(args.sessionId, messageId, { - budgetMs: args.budgetMs ?? STRUCTURED_LAUNCH_PROMPT_SETTLEMENT_BUDGET_MS, - until: 'final' - }) - .catch(() => undefined) - const submission = agentSessionSendSubmission(settled?.value) - switch (submission?.dispatchState) { - case 'accepted': - return { taken: true, messageId } - case 'rejected': - return { - taken: false, - warning: submission.reason ?? "The agent couldn't start, so its prompt wasn't sent." - } - // Still waiting at the budget: a start being retried or still running, or a hand-over whose - // answer was lost and may yet turn accepted. - case 'pending': - case 'unknown': - case undefined: - throw agentSessionRefusalError( - 'agent_session_operation_unknown', - { reason: 'outcomeUnknown' }, - STRUCTURED_LAUNCH_PROMPT_STILL_STARTING - ) - } -} diff --git a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts index 33dd25a462c1..3f184cd50b5c 100644 --- a/src/main/runtime/rpc/methods/agent-launch-surfaces.ts +++ b/src/main/runtime/rpc/methods/agent-launch-surfaces.ts @@ -26,7 +26,7 @@ import type { StructuredAgentSessionHost } from '../../../native-chat/agent-sess import type { RpcContext } from '../core' import { structuredCallerFor } from './structured-agent-session-gate' import { createStructuredAgentSessionForWorktree } from './structured-agent-session-create' -import { deliverStructuredAgentSessionLaunchPrompt } from './agent-launch-structured-prompt' +import { commitStructuredAgentSessionLaunchPrompt } from './agent-launch-structured-prompt' import { deliverTerminalAgentLaunchPrompt } from './agent-launch-terminal-prompt' import { AgentLaunchSessionAlreadyExistsError } from '../../../../shared/agent-launch-session-already-exists' import { createStructuredAgentSessionId } from '../../../../shared/structured-agent-session-create' @@ -101,7 +101,7 @@ export function agentLaunchSurfaceFactory( } }, deliverStructuredPrompt: async ({ sessionId, fence, prompt }) => - deliverStructuredAgentSessionLaunchPrompt({ + commitStructuredAgentSessionLaunchPrompt({ host: getStructuredAgentSessionHost(), caller: operationCallerKey ? { callerKey: operationCallerKey } diff --git a/src/renderer/src/components/browser-pane/annotate/use-browser-page-annotation-send.test.tsx b/src/renderer/src/components/browser-pane/annotate/use-browser-page-annotation-send.test.tsx index 52b040c53c59..9c7ca72e954d 100644 --- a/src/renderer/src/components/browser-pane/annotate/use-browser-page-annotation-send.test.tsx +++ b/src/renderer/src/components/browser-pane/annotate/use-browser-page-annotation-send.test.tsx @@ -8,13 +8,6 @@ import { createTestStore } from '@/store/slices/browser-slice-test-harness' import type { OpenAgentSendPopoverTargetModeArgs } from '@/store/slices/ui' import { BrowserPageAnnotationTray } from './browser-page-annotation-tray' import { useBrowserPageAnnotationSend } from './use-browser-page-annotation-send' -import { enqueueStructuredAgentSessionLaunchPrompt } from '@/components/native-chat/structured-agent-session-outbox-storage' -import { settleStructuredAgentLaunchPrompt } from '@/lib/structured-agent-session-launch-prompt' -import { - FIRST_START_FAILS, - firstMessageStream, - play -} from '@/lib/structured-agent-session-launch-prompt-test-support' const state = vi.hoisted((): { store?: ReturnType } => ({})) vi.mock('@/store', () => ({ @@ -28,11 +21,6 @@ vi.mock('@/store', () => ({ } })) vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) -const client = vi.hoisted(() => ({ call: vi.fn(), subscribe: vi.fn() })) -vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: client.call, - subscribeStructuredAgentSession: client.subscribe -})) vi.mock('./BrowserAnnotationSendMenuContent', () => ({ BrowserAnnotationSendMenuContent: ({ onPromptDelivered }: { onPromptDelivered?: () => void }) => ( @@ -226,55 +214,3 @@ describe('website annotation delivery', () => { expect(notes()).toEqual([]) }) }) - -// Sent to a new chat, whose first message starts its agent: the tray clears only once the agent -// takes the prompt, and a start that never does (or a chat closed mid-wait) leaves it as it was. -describe('website annotations sent to a new chat', () => { - async function sendToNewChat() { - const view = mount() - act(() => view.result.current.handleAnnotationTraySendOpenChange(true)) - const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', mode!.prompt) - const host = firstMessageStream(client, stagedEntry!.clientMessageId) - const delivery = settleStructuredAgentLaunchPrompt({ - launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), - options: { - prompt: mode!.prompt, - promptDelivery: 'submit-after-ready', - onPromptDelivered: () => act(() => mode!.onPromptDelivered?.()) - }, - stagedEntry - })! - return { stream: await host, delivery } - } - - beforeEach(() => { - localStorage.clear() - client.call.mockReset() - client.subscribe.mockReset() - }) - - it('clears the tray once, after a retried start takes the prompt', async () => { - const { stream, delivery } = await sendToNewChat() - const [retry, handedOver, accepted] = FIRST_START_FAILS.retriedThenTaken - stream.next(retry!) - stream.next(handedOver!) - await Promise.resolve() - expect(notes()).toHaveLength(1) - - stream.next(accepted!) - await expect(delivery).resolves.toMatchObject({ delivered: true }) - expect(notes()).toEqual([]) - expect(notes('page-2')).toHaveLength(1) - }) - - it.each([ - ['rejected after its tries', FIRST_START_FAILS.rejectedAfterTries], - ['withdrawn when its chat closes mid-wait', FIRST_START_FAILS.chatClosed] - ])('keeps the annotations when the first message is %s', async (_case, end) => { - const { stream, delivery } = await sendToNewChat() - play(stream, end) - - await expect(delivery).resolves.toMatchObject({ delivered: false }) - expect(notes()).toHaveLength(1) - }) -}) diff --git a/src/renderer/src/components/editor/NotesSendMenu.test.tsx b/src/renderer/src/components/editor/NotesSendMenu.test.tsx index 29abe4844456..64556e6b83db 100644 --- a/src/renderer/src/components/editor/NotesSendMenu.test.tsx +++ b/src/renderer/src/components/editor/NotesSendMenu.test.tsx @@ -1,15 +1,6 @@ -// @vitest-environment happy-dom - import React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { buildNotesSendTargetModeId, NotesSendMenu } from './NotesSendMenu' -import { enqueueStructuredAgentSessionLaunchPrompt } from '@/components/native-chat/structured-agent-session-outbox-storage' -import { settleStructuredAgentLaunchPrompt } from '@/lib/structured-agent-session-launch-prompt' -import { - FIRST_START_FAILS, - firstMessageStream, - play -} from '@/lib/structured-agent-session-launch-prompt-test-support' type ReactElementLike = { type: unknown @@ -34,12 +25,6 @@ const storeMocks = vi.hoisted(() => ({ } })) -const client = vi.hoisted(() => ({ call: vi.fn(), subscribe: vi.fn() })) -vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: client.call, - subscribeStructuredAgentSession: client.subscribe -})) - vi.mock('react', async () => { const actual = await vi.importActual('react') // eslint-disable-line @typescript-eslint/consistent-type-imports -- vi.importActual requires inline import() return { @@ -452,72 +437,3 @@ describe('NotesSendMenu', () => { ) }) }) - -// Sent to a new chat, whose first message starts its agent: the notes are marked sent only once -// the agent takes the prompt, and a start that never does (or a chat closed mid-wait) keeps them. -describe('NotesSendMenu sending to a new chat', () => { - async function sendToNewChat(onDelivered: (notes: readonly TestNote[]) => void) { - resetHookRuntime() - storeMocks.openAgentSendPopoverTargetMode.mockReset() - localStorage.clear() - const tree = renderMenu({ onDelivered }) - const openChange = findByType(tree, 'DropdownMenu').props.onOpenChange - if (typeof openChange !== 'function') { - throw new Error('the menu takes no open change') - } - openChange(true) - const mode: { prompt: string; onPromptDelivered: () => void } = - storeMocks.openAgentSendPopoverTargetMode.mock.calls[0][0] - const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', mode.prompt) - const host = firstMessageStream(client, stagedEntry!.clientMessageId) - const delivery = settleStructuredAgentLaunchPrompt({ - launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), - options: { - prompt: mode.prompt, - promptDelivery: 'submit-after-ready', - onPromptDelivered: mode.onPromptDelivered - }, - stagedEntry - })! - return { stream: await host, delivery } - } - - it('marks the notes sent once, after a retried start takes the prompt', async () => { - const onDelivered = vi.fn() - const { stream, delivery } = await sendToNewChat(onDelivered) - const [retry, handedOver, accepted] = FIRST_START_FAILS.retriedThenTaken - stream.next(retry!) - stream.next(handedOver!) - await Promise.resolve() - expect(onDelivered).not.toHaveBeenCalled() - - stream.next(accepted!) - await expect(delivery).resolves.toMatchObject({ delivered: true }) - expect(onDelivered).toHaveBeenCalledOnce() - expect(onDelivered).toHaveBeenCalledWith([{ id: 'note-1' }]) - }) - - it.each([ - ['rejected after its tries', FIRST_START_FAILS.rejectedAfterTries], - ['withdrawn when its chat closes mid-wait', FIRST_START_FAILS.chatClosed] - ])('keeps the notes when the first message is %s', async (_case, end) => { - const onDelivered = vi.fn() - const { stream, delivery } = await sendToNewChat(onDelivered) - play(stream, end) - - await expect(delivery).resolves.toMatchObject({ delivered: false }) - expect(onDelivered).not.toHaveBeenCalled() - }) - - it('marks the notes sent once, when sent again from the notes after a start that failed for good', async () => { - const onDelivered = vi.fn() - const first = await sendToNewChat(onDelivered) - play(first.stream, FIRST_START_FAILS.rejectedAfterTries) - await expect(first.delivery).resolves.toMatchObject({ delivered: false }) - - const again = await sendToNewChat(onDelivered) - play(again.stream, FIRST_START_FAILS.retriedThenTaken) - await expect(again.delivery).resolves.toMatchObject({ delivered: true }) - expect(onDelivered).toHaveBeenCalledOnce() - }) -}) diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 96b7ad0785d8..59b1fa10802b 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -62,8 +62,8 @@ const PIECES: Record - translate('components.native-chat.failureWords.installFirst', COPY.installFirst), + installThenRetry: () => + translate('components.native-chat.failureWords.installThenRetry', COPY.installThenRetry), installThenRunCommand: (values) => translate( 'components.native-chat.failureWords.installThenRunCommand', diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts index afa1328f174c..cdfa7b6fbc2c 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.test.ts @@ -563,69 +563,6 @@ describe('the notice on each message that did not go through', () => { }) }) -// A launch prompt belongs to where it was sent from (notes, review comments, a fix action), which -// still holds it as unsent once the host rejects it for good: that is where it goes again. -describe('the notice on a launch prompt the host rejected for good', () => { - const notInstalled = { - kind: 'rejected' as const, - reason: "Claude isn't installed.", - rejection: { kind: 'providerMissing' as const } - } - - it('says why and to send it again from its source, with no Retry here', () => { - const retry = vi.fn() - const notices = structuredAgentSessionDeliveryNotices( - [ - entry('launch', { - source: 'launch', - heldBySource: true, - state: 'rejected', - lastFailure: notInstalled - }), - entry('composer', { source: 'launch', state: 'rejected', lastFailure: notInstalled }), - entry('typed', { state: 'rejected', lastFailure: notInstalled }) - ], - 'Claude', - retry, - [], - [], - NOT_FAILED_HERE - ) - - const launch = notices.get(agentJournalSubmissionKey('launch')) - expect(launch?.onRetry).toBeUndefined() - expect(launch?.text).toBe( - "Not sent: Claude isn't installed. Install it first. Send it again from where you started it." - ) - // A composer's launch prompt has nowhere else to be sent from: it keeps the chat's Retry. - expect(notices.get(agentJournalSubmissionKey('composer'))?.onRetry).toBeTypeOf('function') - // A message typed in the chat keeps its own Retry, and the same step to take first. - const typed = notices.get(agentJournalSubmissionKey('typed')) - expect(typed?.onRetry).toBeTypeOf('function') - expect(typed?.text).toBe("Claude isn't installed. Install it first.") - }) - - it('keeps Retry on a launch prompt only held, not rejected', () => { - const notices = structuredAgentSessionDeliveryNotices( - [ - entry('launch', { - source: 'launch', - heldBySource: true, - state: 'queued', - lastFailure: { kind: 'refused', code: 'agent_session_operation_capacity' } - }) - ], - 'Claude', - vi.fn(), - [], - [], - NOT_FAILED_HERE - ) - - expect(notices.get(agentJournalSubmissionKey('launch'))?.onRetry).toBeTypeOf('function') - }) -}) - describe('the notice on a message whose agent start failed', () => { function queued( id: string, diff --git a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts index 158f11cdb546..5dbe834fd471 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-delivery-notices.ts @@ -181,23 +181,6 @@ function deliveryNoticeText( ) } -/** A launch prompt the host rejected for good whose caller still holds it (notes, review comments, - * a fix action) is sent again from there, so a Retry here would deliver it while that caller never - * learns it went. A composer's prompt has no such caller and keeps its Retry. */ -function sentFromSource(entry: StructuredAgentSessionOutboxEntry): boolean { - return entry.heldBySource === true && entry.state === 'rejected' -} - -function sourcedMessageNotSentText(reason: string): string { - return joinSentences([ - translate('components.native-chat.launchPromptNotSent', 'Not sent: {{reason}}', { reason }), - translate( - 'components.native-chat.launchPromptSendAgain', - 'Send it again from where you started it.' - ) - ]) -} - /** Keyed by the message id the transcript renders each entry under; `agentName` is the chat's * agent, for the words. */ export function structuredAgentSessionDeliveryNotices( @@ -239,18 +222,14 @@ export function structuredAgentSessionDeliveryNotices( const retryControl = stalledFrom === -1 || index <= stalledFrom const text = deliveryNoticeText( entry, - sentFromSource(entry) ? { agentName, retryControl: true } : { agentName, retryControl }, + { agentName, retryControl }, rejected.get(entry.clientMessageId), startFailures, failedHere ) notices.set( agentJournalSubmissionKey(entry.clientMessageId), - sentFromSource(entry) - ? { text: sourcedMessageNotSentText(text) } - : retryControl - ? { text, ...retryControlFor(entry.clientMessageId) } - : { text } + retryControl ? { text, ...retryControlFor(entry.clientMessageId) } : { text } ) } } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts index 7b46118087f0..ce270d60ddba 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts @@ -180,7 +180,7 @@ export function appendStructuredAgentSessionOutboxMessage( sessionId: string, text: string, attachments: readonly StructuredAgentSessionAttachment[] = [], - marking: Pick = {} + source?: 'launch' ): StructuredAgentSessionOutboxEntry | null { const entry = { ...createStructuredAgentSessionOutboxEntry({ @@ -190,7 +190,7 @@ export function appendStructuredAgentSessionOutboxMessage( attachments, queuedAt: Date.now() }), - ...marking + ...(source ? { source } : {}) } return commitStructuredAgentSessionOutbox( sessionId, @@ -203,13 +203,9 @@ export function appendStructuredAgentSessionOutboxMessage( export function enqueueStructuredAgentSessionLaunchPrompt( sessionId: string, - text: string, - heldBySource = false + text: string ): StructuredAgentSessionOutboxEntry | null { - return appendStructuredAgentSessionOutboxMessage(sessionId, text, [], { - source: 'launch', - ...(heldBySource ? { heldBySource: true } : {}) - }) + return appendStructuredAgentSessionOutboxMessage(sessionId, text, [], 'launch') } export function discardStructuredAgentSessionLaunchOutbox(sessionId: string): void { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx index c07d5c2efeb7..150b520bcd32 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.test.tsx @@ -73,8 +73,6 @@ import { writeOutbox } from './structured-agent-session-outbox-storage' import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' -import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' -import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' import { useStructuredAgentSession } from './use-structured-agent-session' const SESSION = 'session-1' @@ -276,65 +274,15 @@ describe('a message the host withdrew at a Stop', () => { expect(readNativeChatDraftCache(PANE)).toBe('') }) - it.each([ - ['a composer shows the chat', PANE], - ['no composer shows the chat', null] - ])( - 'keeps a launch prompt its caller holds, which a Stop withdrew while its start retried, as not sent, when %s', - (_case, composer) => { - const launch = enqueueStructuredAgentSessionLaunchPrompt(SESSION, 'launch text', true)! - const id = launch.clientMessageId - const { result, rerender } = renderOutbox(composer) - rerender({ - submissions: [ - submission(id, { - startRetry: { - attempts: 1, - reason: 'An account switch is in progress.', - rejection: { kind: 'accountSwitchInProgress' }, - failedAt: 1, - nextAttemptAt: 15_001 - } - }) - ] - }) - - rerender({ submissions: [withdrawn(id, { rejection: { kind: 'cancelled' } })] }) - - // Its source still holds it as unsent; a composer copy would send it twice. - expect(readNativeChatDraftCache(PANE)).toBe('') - expect(result.current.outbox).toEqual([ - expect.objectContaining({ - clientMessageId: id, - state: 'rejected', - lastFailure: expect.objectContaining({ rejection: { kind: 'cancelled' } }) - }) - ]) - const notice = structuredAgentSessionDeliveryNotices( - result.current.outbox, - 'Claude', - vi.fn(), - [], - [], - new Set() - ).get(agentJournalSubmissionKey(id)) - expect(notice).toEqual({ - text: 'Not sent: This message was withdrawn before the agent started it. Send it again from where you started it.' - }) - } - ) - - it("gives a composer's launch prompt a Stop withdrew back to the composer, which holds nothing else", () => { + it('drops a withdrawn launch prompt where no composer shows the chat', () => { const launch = enqueueStructuredAgentSessionLaunchPrompt(SESSION, 'launch text')! - const { result, rerender } = renderOutbox() - rerender({ submissions: [submission(launch.clientMessageId)] }) + const { result, rerender } = renderOutbox(null) - rerender({ - submissions: [withdrawn(launch.clientMessageId, { rejection: { kind: 'cancelled' } })] - }) + expect(() => rerender({ submissions: [withdrawn(launch.clientMessageId)] })).not.toThrow() - expect(readNativeChatDraftCache(PANE)).toBe('launch text') expect(result.current.outbox).toEqual([]) + expect(readOutbox(SESSION)).toEqual([]) + expect(readNativeChatDraftCache(PANE)).toBe('') }) }) @@ -380,19 +328,6 @@ describe('a message a Stop took out of the outbox before the host held it', () = expect(readNativeChatDraftCache(PANE)).toBe('hello') }) - it('keeps a launch prompt its caller holds, not yet sent, as not sent, and gives it nothing in the composer', () => { - const launch = enqueueStructuredAgentSessionLaunchPrompt(SESSION, 'launch text', true)! - mocks.call.mockImplementation(() => new Promise(() => {})) - const { result } = renderOutbox() - - act(() => result.current.withdrawUnsent()) - - expect(readNativeChatDraftCache(PANE)).toBe('') - expect(result.current.outbox).toEqual([ - expect.objectContaining({ clientMessageId: launch.clientMessageId, state: 'rejected' }) - ]) - }) - it('leaves a message waiting on Retry where it is, and gives back only what it withdrew', async () => { writeOutbox(SESSION, [ { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts index 2836df040663..40e69565ef07 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-withdrawn-message-restore.ts @@ -25,8 +25,7 @@ function restoreWithdrawnMessages( getStructuredAgentSessionOutbox(sessionId).map((entry) => entry.clientMessageId) ) for (const entry of withdrawn) { - // A launch prompt its caller still holds goes back there, not to the composer. - if (!held.has(entry.clientMessageId) || entry.heldBySource === true) { + if (!held.has(entry.clientMessageId)) { continue } const blocks = entry.body.blocks diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx index 383b7d28477a..8a215319ec68 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-admission.test.tsx @@ -14,20 +14,13 @@ const mocks = vi.hoisted(() => ({ call: vi.fn() })) -const launchClient = vi.hoisted(() => ({ subscribe: vi.fn() })) vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: mocks.call, - subscribeStructuredAgentSession: launchClient.subscribe + callStructuredAgentSession: mocks.call })) import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' import { enqueueStructuredAgentSessionLaunchPrompt } from './structured-agent-session-outbox-storage' import { settleStructuredAgentLaunchPrompt } from '@/lib/structured-agent-session-launch-prompt' -import { - FIRST_START_FAILS, - firstMessageStream, - play -} from '@/lib/structured-agent-session-launch-prompt-test-support' import { structuredAgentSessionDeliveryNotices } from './structured-agent-session-delivery-notices' import { agentJournalSubmissionKey } from '../../../../shared/agent-session-journal-item-key' import type { StructuredAgentSessionOutboxEntry } from '../../../../shared/structured-agent-session-outbox' @@ -217,17 +210,9 @@ describe('structured agent session outbox admission', () => { // The launch settlement dispatches outside this hook's single-flight, so nothing may race it. expect(sentTexts()).not.toContain('after launch') - // A pending launch prompt is delivered once the agent takes it. - const taken = firstMessageStream( - { call: vi.fn(), subscribe: launchClient.subscribe }, - stagedEntry.clientMessageId - ) await act(async () => admission.resolve(submissionResult(stagedEntry.clientMessageId, dispatchState, 1)) ) - if (dispatchState === 'pending') { - play(await taken, FIRST_START_FAILS.retriedThenTaken) - } await expect(delivery).resolves.toEqual({ delivered: true, failureNotified: false }) await waitFor(() => expect(sentTexts()).toContain('after launch')) diff --git a/src/renderer/src/components/pull-request-page/checks/fix-launch.ts b/src/renderer/src/components/pull-request-page/checks/fix-launch.ts index 7a8329bd7c85..3944540ef66e 100644 --- a/src/renderer/src/components/pull-request-page/checks/fix-launch.ts +++ b/src/renderer/src/components/pull-request-page/checks/fix-launch.ts @@ -64,8 +64,6 @@ export async function startFixChecksFromDialog(args: { launchSource: 'task_page', telemetrySource: 'sidebar', promptDelivery: 'submit-after-ready', - // The dialog says "Started an AI agent" on true: only once the agent took the prompt. - waitForPromptDelivery: true, agentOverride: args.agent, agentArgs: args.agentArgs, openModalFallback: () => { diff --git a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx deleted file mode 100644 index da3b27bf3d71..000000000000 --- a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-launch-delivery.test.tsx +++ /dev/null @@ -1,239 +0,0 @@ -// @vitest-environment happy-dom - -// "Resolve comments with AI" writes to GitHub (fixing replies, resolved threads) only once the -// launch prompt reaches the agent. The chat's first message starts that agent, so a start that -// fails must post nothing and hand the comments back for a retry; one a retry clears posts once. - -import { act, cleanup, renderHook } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - call: vi.fn(), - subscribe: vi.fn(), - launchAgentInNewTab: vi.fn(), - toastError: vi.fn(), - toastSuccess: vi.fn() -})) - -vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: mocks.call, - subscribeStructuredAgentSession: mocks.subscribe -})) -vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab: mocks.launchAgentInNewTab })) -vi.mock('@/lib/focus-terminal-tab-surface', () => ({ focusTerminalTabSurface: vi.fn() })) -vi.mock('sonner', () => ({ toast: { error: mocks.toastError, success: mocks.toastSuccess } })) - -import type { PRComment } from '../../../../../shared/github/comment-types' -import { enqueueStructuredAgentSessionLaunchPrompt } from '@/components/native-chat/structured-agent-session-outbox-storage' -import { settleStructuredAgentLaunchPrompt } from '@/lib/structured-agent-session-launch-prompt' -import { - FIRST_START_FAILS, - firstMessageStream, - play, - type FirstMessageChange -} from '@/lib/structured-agent-session-launch-prompt-test-support' -import { - clearPendingPRCommentAiAck, - type PendingPRCommentAiAck -} from '../pr-comments-ai-launch-ack' -import { runSourceControlAgentActionStart } from '../runSourceControlAgentActionStart' -import { useChecksPanelAiAcknowledgement } from './use-checks-panel-ai-acknowledgement' - -const REVIEW_KEY = 'repo-1::42::sha-1' - -function comment(overrides: Partial): PRComment { - return { - id: 1, - author: 'alice', - authorAvatarUrl: '', - body: 'Please update this.', - createdAt: '2026-05-14T00:00:00Z', - url: 'https://github.com/acme/widgets/pull/42#discussion_r1', - ...overrides - } -} - -/** An open review thread and a conversation comment: once the prompt is delivered, the thread is - * resolved and the conversation comment gets one fixing reply. */ -function resolution(): PendingPRCommentAiAck { - const target = { repoPath: '/repos/widgets', repoId: 'repo-1', prNumber: 42 } - return { - reviewContextKey: REVIEW_KEY, - provider: 'github', - selectedGroups: [ - { - kind: 'thread', - threadId: 'T1', - root: comment({ id: 10, threadId: 'T1', path: 'src/a.ts', isResolved: false }), - replies: [] - }, - { - kind: 'standalone', - comment: comment({ id: 20, url: 'https://github.com/acme/widgets/pull/42#issuecomment-9' }) - } - ], - githubTarget: { ...target, prRepo: { owner: 'acme', repo: 'widgets' } }, - githubResolveTarget: target - } -} - -type AcknowledgementInput = Parameters[0] - -function acknowledgement() { - const pendingCommentResolutionRef: AcknowledgementInput['pendingCommentResolutionRef'] = { - current: resolution() - } - const model = { - addPRConversationComment: vi.fn(async () => ({ - ok: true, - comment: comment({ id: 30 }) - })), - addPRReviewCommentReply: vi.fn(async () => ({ - ok: true, - comment: comment({ id: 31, threadId: 'T1' }) - })), - resolveReviewThread: vi.fn(async () => true), - asyncResultKeyRef: { current: REVIEW_KEY }, - claimedCommentResolutionRef: { current: null }, - commentsRef: { current: [] }, - commentsSelectionClearTokenRef: { current: 0 }, - pendingCommentResolutionRef, - commentResolutionLaunchAcceptedRef: { current: false }, - setCommentResolutionAckBusyNow: vi.fn(), - setComments: vi.fn(), - setCommentsSelectionClearRequest: vi.fn(), - settings: null, - fetchComments: vi.fn(async () => {}), - fetchGitLabDetails: vi.fn(async () => {}) - } satisfies AcknowledgementInput - const hook = renderHook(() => useChecksPanelAiAcknowledgement(model)) - return { model, hook } -} - -/** Starts the launch; the first message's stream is the host's to play. */ -function resolveCommentsWithAi(hook: ReturnType['hook']) { - const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'Fix the comments') - const host = firstMessageStream(mocks, stagedEntry!.clientMessageId) - mocks.launchAgentInNewTab.mockImplementation(() => ({ - surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'session-1' }, - promptDeliveryResult: settleStructuredAgentLaunchPrompt({ - launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), - options: { prompt: 'Fix the comments', promptDelivery: 'submit-after-ready' }, - stagedEntry - }) - })) - const launched = act(() => - runSourceControlAgentActionStart({ - selectedAgent: 'claude', - trimmedCommandInput: 'Fix the comments', - agentArgs: '', - agentArgsApply: false, - commandTemplate: '{basePrompt}', - saveTargetValue: 'none', - actionId: 'resolveComments', - repoId: null, - settings: null, - repo: null, - worktreeId: 'wt-1', - groupId: 'wt-1', - promptDelivery: 'submit-after-ready', - launchPlatform: 'linux', - launchSource: 'task_page', - onStart: undefined, - onLaunchAccepted: hook.result.current.handleLaunchAccepted, - onLaunchAborted: hook.result.current.handleLaunchAborted, - onLaunched: () => - hook.result.current.consumeClaimedCommentResolutionAfterDeliveryRef.current(), - onClose: vi.fn() - }) - ) - return { host, launched } -} - -describe('Resolve comments with AI, when the chat starts on its first message', () => { - beforeEach(() => { - vi.clearAllMocks() - localStorage.clear() - clearPendingPRCommentAiAck() - }) - afterEach(() => { - cleanup() - }) - - function expectNothingPosted(model: ReturnType['model']) { - expect(model.addPRReviewCommentReply).not.toHaveBeenCalled() - expect(model.addPRConversationComment).not.toHaveBeenCalled() - expect(model.resolveReviewThread).not.toHaveBeenCalled() - expect(mocks.toastSuccess).not.toHaveBeenCalled() - } - - it('posts its reply and resolves its thread exactly once, after a retried start takes the prompt', async () => { - const { model, hook } = acknowledgement() - const { host, launched } = resolveCommentsWithAi(hook) - const stream = await host - const [retry, handedOver, accepted] = FIRST_START_FAILS.retriedThenTaken - - await act(async () => { - stream.next(retry!) - stream.next(handedOver!) - }) - // The agent is still starting: nothing is posted, and the comments stay claimed. - expectNothingPosted(model) - expect(model.claimedCommentResolutionRef.current).toMatchObject({ - reviewContextKey: REVIEW_KEY - }) - - stream.next(accepted!) - await expect(launched).resolves.toBe(true) - await vi.waitFor(() => expect(model.resolveReviewThread).toHaveBeenCalledTimes(1)) - expect(model.addPRConversationComment).toHaveBeenCalledTimes(1) - expect(model.pendingCommentResolutionRef.current).toBeNull() - // Its replies wait on the delivery, so the chat leaves the prompt to it rather than its Retry. - // Checked once the launch has settled, so a failure here leaves no launch running. - expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith( - expect.objectContaining({ promptHeldBySource: true }) - ) - }) - - const endings: [string, readonly FirstMessageChange[]][] = [ - ['rejected after its tries', FIRST_START_FAILS.rejectedAfterTries], - ['withdrawn when its chat closes mid-wait', FIRST_START_FAILS.chatClosed], - ...['notSignedIn', 'providerMissing', 'providerExited'].map( - (kind): [string, readonly FirstMessageChange[]] => [ - `refused at once (${kind})`, - [{ dispatchState: 'rejected', rejection: { kind } }] - ] - ) - ] - it.each(endings)( - 'posts nothing when the first message is %s, and hands the comments back', - async (_c, end) => { - const { model, hook } = acknowledgement() - const { host, launched } = resolveCommentsWithAi(hook) - play(await host, end) - - await expect(launched).resolves.toBe(false) - expectNothingPosted(model) - expect(model.pendingCommentResolutionRef.current).toMatchObject({ - reviewContextKey: REVIEW_KEY - }) - } - ) - - // The source owns the retry of a launch prompt the host rejected for good: the chat offers none, - // and sending again from the checks panel is what posts, once. - it('posts once when sent again from the checks panel after a start that failed for good', async () => { - const { model, hook } = acknowledgement() - const first = resolveCommentsWithAi(hook) - play(await first.host, FIRST_START_FAILS.rejectedAfterTries) - await expect(first.launched).resolves.toBe(false) - expectNothingPosted(model) - - const again = resolveCommentsWithAi(hook) - play(await again.host, FIRST_START_FAILS.retriedThenTaken) - await expect(again.launched).resolves.toBe(true) - - await vi.waitFor(() => expect(model.resolveReviewThread).toHaveBeenCalledTimes(1)) - expect(model.addPRConversationComment).toHaveBeenCalledTimes(1) - }) -}) diff --git a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts index b5146001429d..2f5c044ab1d7 100644 --- a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts @@ -109,9 +109,7 @@ export async function runSourceControlAgentActionStart({ agentArgs: launchAgentArgs, promptDelivery, launchPlatform, - launchSource, - // Its caller's payload (review replies, a check fix) waits on the delivery below. - promptHeldBySource: true + launchSource }) launched = Boolean(result) if (result?.surface.kind === 'local-terminal') { diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts b/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts index f6302b0f1702..4a055a613eec 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts @@ -2,7 +2,6 @@ import { toast } from 'sonner' import type { AppState } from '@/store' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' -import { launchPromptDelivered } from '@/lib/launch-prompt-delivered' import { getConnectionId } from '@/lib/connection-context' import { planAgentCliArgsSuffix } from '@/lib/tui-agent-startup' import { @@ -164,8 +163,7 @@ export async function launchSourceControlRecoveryAgentWithDefault({ agentArgs: savedRecipe.agentArgs, promptDelivery: 'submit-after-ready', launchPlatform: activeSourceControlLaunchPlatform, - launchSource: 'source_control_recovery', - promptHeldBySource: true + launchSource: 'source_control_recovery' }) if (!result) { toast.error(copy.launchCommandUnavailable) @@ -175,10 +173,6 @@ export async function launchSourceControlRecoveryAgentWithDefault({ if (result.surface.kind === 'local-terminal') { focusTerminalTabSurface(result.surface.tabId) } - // "Started" only once the agent took the prompt; a failed start is reported on its message. - if (!(await launchPromptDelivered(result))) { - return false - } toast.success(copy.success) return true } diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts b/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts index 203c90295ca5..bb2f1cf02f73 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.test.ts @@ -1,11 +1,7 @@ import React from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { - QuickLaunchAgentMenuItems, - reportLaunchPromptNotTaken, - shouldShowLaunchWatchdogTimeout -} from './QuickLaunchButton' +import { QuickLaunchAgentMenuItems, shouldShowLaunchWatchdogTimeout } from './QuickLaunchButton' const { shortcutLabelMock, @@ -228,19 +224,4 @@ describe('shouldShowLaunchWatchdogTimeout', () => { }) ).toBe(true) }) - - // Notes and annotations sent to a new agent stay unsent when it never takes the prompt; the menu - // they were sent from says so, since that is where they go again. - it.each([ - [{ delivered: false, failureNotified: false }, 1], - [{ delivered: false, failureNotified: true }, 0], - [{ delivered: true, failureNotified: false }, 0] - ])('reports a launch prompt the agent did not take (%o)', async (delivery, toasts) => { - const { toast } = await import('sonner') - vi.mocked(toast.error).mockClear() - - await reportLaunchPromptNotTaken(Promise.resolve(delivery)) - - expect(toast.error).toHaveBeenCalledTimes(toasts) - }) }) diff --git a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx index 1dede10528bc..026944575c83 100644 --- a/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx +++ b/src/renderer/src/components/tab-bar/QuickLaunchButton.tsx @@ -18,23 +18,6 @@ import { import { translate } from '@/i18n/i18n' import { useStructuredAgentLaunchStatus } from '@/lib/structured-agent-session-launch' -/** Says, once the launch prompt settles, that the agent did not take it, unless something already - * said so. */ -export async function reportLaunchPromptNotTaken( - delivery: Promise<{ delivered: boolean; failureNotified: boolean }> -): Promise { - const settled = await delivery.catch(() => ({ delivered: false, failureNotified: false })) - if (settled.delivered || settled.failureNotified) { - return - } - toast.error( - translate( - 'components.native-chat.launchPromptNotTaken', - "The agent didn't take the prompt, so nothing was marked sent. Send it again from here." - ) - ) -} - export type QuickLaunchAgentMenuItemsProps = { worktreeId: string groupId: string @@ -170,11 +153,6 @@ function QuickLaunchAgentMenuItemsInner({ ) return } - // A caller that marks something sent on delivery (notes, annotations) keeps it unsent when - // the agent never takes the prompt; say so, since this menu is where it goes again. - if (onPromptDelivered && result.promptDeliveryResult) { - void reportLaunchPromptNotTaken(result.promptDeliveryResult) - } if (result.surface.kind !== 'local-terminal') { return } diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 142cccecfed6..e8aac316801d 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2758,7 +2758,7 @@ "hostFaultTryAgain": "Orca ran into a problem, so this didn't go through. Try again.", "hostRestarted": "Orca restarted before this message was sent.", "hostStopped": "{{agent}} never finished starting, so Orca stopped it.", - "installFirst": "Install it first.", + "installThenRetry": "Install it, then retry.", "installThenRunCommand": "Install it, then run /{{command}} again.", "installThenSend": "Install it, then send your message again.", "managedAccountEnvOverride": "This Claude launch sets its own Anthropic sign-in variables. Remove them to use a managed Claude account.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index dd03a0cf7939..0a3eb983be4c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17522,7 +17522,7 @@ "signInThenRunCommand": "Sign in, then run /{{command}} again.", "signInThenSend": "Sign in, then send your message again.", "providerMissing": "{{agent}} isn't installed.", - "installFirst": "Install it first.", + "installThenRetry": "Install it, then retry.", "installThenRunCommand": "Install it, then run /{{command}} again.", "installThenSend": "Install it, then send your message again.", "historyTooLarge": "This conversation's history is too large to restore here.", @@ -17985,9 +17985,6 @@ "dismissDeliveryNotice": "Dismiss", "messageNotSent": "Message not sent", "startRetrying": "Orca will try again shortly.", - "launchPromptSendAgain": "Send it again from where you started it.", - "launchPromptNotSent": "Not sent: {{reason}}", - "launchPromptNotTaken": "The agent didn't take the prompt, so nothing was marked sent. Send it again from here.", "deliveryUnconfirmed": "Delivery unconfirmed — check chat before retrying", "queuedMessages": { "behindReturnedHold": "Waiting — a message ahead needs attention", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index dcba003c0d19..cb2f6d22497c 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14954,7 +14954,7 @@ "signInThenRunCommand": "Inicie sesión y vuelva a ejecutar /{{command}}.", "signInThenSend": "Inicie sesión y vuelva a enviar su mensaje.", "providerMissing": "{{agent}} no está instalado.", - "installFirst": "Instálelo primero.", + "installThenRetry": "Instálelo y vuelva a intentarlo.", "installThenRunCommand": "Instálelo y vuelva a ejecutar /{{command}}.", "installThenSend": "Instálelo y vuelva a enviar su mensaje.", "historyTooLarge": "El historial de esta conversación es demasiado grande para restaurarlo aquí.", @@ -15192,9 +15192,6 @@ "dismissDeliveryNotice": "Descartar", "messageNotSent": "Mensaje no enviado", "startRetrying": "Orca lo intentará de nuevo en breve.", - "launchPromptSendAgain": "Vuelve a enviarlo desde donde lo iniciaste.", - "launchPromptNotSent": "No enviado: {{reason}}", - "launchPromptNotTaken": "El agente no recibió el prompt, así que no se marcó nada como enviado. Vuelve a enviarlo desde aquí.", "deliveryUnconfirmed": "Entrega sin confirmar — revisa el chat antes de reintentar", "subagents": { "unnamed": "Subagente" diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index eba936b664c1..ba96cb29d824 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17420,7 +17420,7 @@ "signInThenRunCommand": "Connectez-vous, puis relancez /{{command}}.", "signInThenSend": "Connectez-vous, puis renvoyez votre message.", "providerMissing": "{{agent}} n'est pas installé.", - "installFirst": "Installez-le d'abord.", + "installThenRetry": "Installez-le, puis réessayez.", "installThenRunCommand": "Installez-le, puis relancez /{{command}}.", "installThenSend": "Installez-le, puis renvoyez votre message.", "historyTooLarge": "L'historique de cette conversation est trop volumineux pour être restauré ici.", @@ -17877,9 +17877,6 @@ "dismissDeliveryNotice": "Ignorer", "messageNotSent": "Message non envoyé", "startRetrying": "Orca réessaiera sous peu.", - "launchPromptSendAgain": "Renvoyez-le depuis l'endroit où vous l'avez lancé.", - "launchPromptNotSent": "Non envoyé : {{reason}}", - "launchPromptNotTaken": "L'agent n'a pas pris le prompt, donc rien n'a été marqué comme envoyé. Renvoyez-le d'ici.", "deliveryUnconfirmed": "Livraison non confirmée — vérifiez le chat avant de réessayer", "queuedMessages": { "alreadySent": "Ce message a déjà été envoyé.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 85238fd9e91a..097c149d6cc0 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17356,7 +17356,7 @@ "signInThenRunCommand": "サインインしてから、/{{command}} をもう一度実行してください。", "signInThenSend": "サインインしてから、メッセージをもう一度送信してください。", "providerMissing": "{{agent}} はインストールされていません。", - "installFirst": "先にインストールしてください。", + "installThenRetry": "インストールしてから、もう一度お試しください。", "installThenRunCommand": "インストールしてから、/{{command}} をもう一度実行してください。", "installThenSend": "インストールしてから、メッセージをもう一度送信してください。", "historyTooLarge": "この会話の履歴は大きすぎるため、ここでは復元できません。", @@ -17813,9 +17813,6 @@ "dismissDeliveryNotice": "閉じる", "messageNotSent": "メッセージは送信されませんでした", "startRetrying": "Orca がまもなく再試行します。", - "launchPromptSendAgain": "開始した場所からもう一度送信してください。", - "launchPromptNotSent": "未送信: {{reason}}", - "launchPromptNotTaken": "Agent がプロンプトを受け取らなかったため、送信済みにはなっていません。ここからもう一度送信してください。", "deliveryUnconfirmed": "配信を確認できません — 再試行する前にチャットを確認してください", "queuedMessages": { "alreadySent": "このメッセージはすでに送信されています。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 5f7bc9ae4c68..d4ba8c464d75 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17356,7 +17356,7 @@ "signInThenRunCommand": "로그인한 다음 /{{command}}을(를) 다시 실행하세요.", "signInThenSend": "로그인한 다음 메시지를 다시 보내세요.", "providerMissing": "{{agent}}이(가) 설치되어 있지 않습니다.", - "installFirst": "먼저 설치하세요.", + "installThenRetry": "설치한 다음 다시 시도하세요.", "installThenRunCommand": "설치한 다음 /{{command}}을(를) 다시 실행하세요.", "installThenSend": "설치한 다음 메시지를 다시 보내세요.", "historyTooLarge": "이 대화의 기록이 너무 커서 여기에서 복원할 수 없습니다.", @@ -17813,9 +17813,6 @@ "dismissDeliveryNotice": "닫기", "messageNotSent": "메시지가 전송되지 않음", "startRetrying": "Orca가 곧 다시 시도합니다.", - "launchPromptSendAgain": "시작한 곳에서 다시 보내세요.", - "launchPromptNotSent": "보내지 않음: {{reason}}", - "launchPromptNotTaken": "에이전트가 프롬프트를 받지 않아 보낸 것으로 표시되지 않았습니다. 여기에서 다시 보내세요.", "deliveryUnconfirmed": "전달 확인 안 됨 — 다시 시도하기 전에 채팅을 확인하세요", "queuedMessages": { "alreadySent": "이 메시지는 이미 전송되었습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1c39dc6b201f..589b584d523d 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17321,7 +17321,7 @@ "signInThenRunCommand": "请登录,然后重新运行 /{{command}}。", "signInThenSend": "请登录,然后重新发送您的消息。", "providerMissing": "{{agent}} 未安装。", - "installFirst": "请先安装。", + "installThenRetry": "请安装,然后重试。", "installThenRunCommand": "请安装,然后重新运行 /{{command}}。", "installThenSend": "请安装,然后重新发送您的消息。", "historyTooLarge": "此对话的历史记录过大,无法在此恢复。", @@ -17778,9 +17778,6 @@ "dismissDeliveryNotice": "关闭", "messageNotSent": "消息未发送", "startRetrying": "Orca 将很快重试。", - "launchPromptSendAgain": "请从你发起它的地方重新发送。", - "launchPromptNotSent": "未发送:{{reason}}", - "launchPromptNotTaken": "智能体没有接收提示词,因此没有标记为已发送。请从这里重新发送。", "deliveryUnconfirmed": "无法确认送达 — 重试前请检查聊天", "queuedMessages": { "alreadySent": "此消息已发送。", diff --git a/src/renderer/src/lib/agent-session-launch-plan.test.ts b/src/renderer/src/lib/agent-session-launch-plan.test.ts index 031b9f403c88..73b475229bfb 100644 --- a/src/renderer/src/lib/agent-session-launch-plan.test.ts +++ b/src/renderer/src/lib/agent-session-launch-plan.test.ts @@ -90,26 +90,6 @@ describe('planAgentSessionLaunch', () => { ) }) - it.each([true, false])( - 'hands the settle loop a caller saying it holds the prompt: %s', - async (held) => { - const plan = planAgentSessionLaunch(store, { - agent: 'claude', - workspace: { kind: 'folder', worktreeId: 'folder:ws-1' }, - prompt: 'Review this', - promptHeldBySource: held - }) - - await plan.launch(hooks) - expect(mocks.beginStructuredAgentLaunchSettlement).toHaveBeenCalledWith( - 'folder:ws-1', - 'claude', - expect.objectContaining({ promptHeldBySource: held }), - hooks - ) - } - ) - it('sends no delivery fields the request did not carry', async () => { const plan = planAgentSessionLaunch(store, { agent: 'codex', diff --git a/src/renderer/src/lib/agent-session-launch-plan.ts b/src/renderer/src/lib/agent-session-launch-plan.ts index 47d78e808473..7e72412e4fb6 100644 --- a/src/renderer/src/lib/agent-session-launch-plan.ts +++ b/src/renderer/src/lib/agent-session-launch-plan.ts @@ -24,8 +24,6 @@ import type { StructuredAgentLaunchOptions } from '@/lib/structured-agent-sessio export type AgentSessionLaunchRequest = AgentLaunchRouteArgs & { resumeFrom?: StructuredAgentSessionResumeSource onPromptDelivered?: () => void - /** See `StructuredAgentLaunchOptions.promptHeldBySource`. */ - promptHeldBySource?: boolean } /** @@ -41,7 +39,6 @@ export type AgentSessionLaunchVerdict = { promptDelivery?: NativeChatLaunchPromptDelivery resumeFrom?: StructuredAgentSessionResumeSource onPromptDelivered?: () => void - promptHeldBySource?: boolean } export type AgentSessionStructuredFeasibilityRequest = AgentLaunchRouteArgs & { @@ -73,10 +70,7 @@ function structuredLaunchOptions(verdict: AgentSessionLaunchVerdict): Structured ...(verdict.prompt !== undefined ? { prompt: verdict.prompt } : {}), ...(verdict.promptDelivery ? { promptDelivery: verdict.promptDelivery } : {}), ...(verdict.resumeFrom ? { resumeFrom: verdict.resumeFrom } : {}), - ...(verdict.onPromptDelivered ? { onPromptDelivered: verdict.onPromptDelivered } : {}), - ...(verdict.promptHeldBySource !== undefined - ? { promptHeldBySource: verdict.promptHeldBySource } - : {}) + ...(verdict.onPromptDelivered ? { onPromptDelivered: verdict.onPromptDelivered } : {}) } } @@ -141,9 +135,6 @@ export function planAgentSessionLaunch( ...(request.prompt !== undefined ? { prompt: request.prompt } : {}), ...(request.promptDelivery ? { promptDelivery: request.promptDelivery } : {}), ...(request.resumeFrom ? { resumeFrom: request.resumeFrom } : {}), - ...(request.onPromptDelivered ? { onPromptDelivered: request.onPromptDelivered } : {}), - ...(request.promptHeldBySource !== undefined - ? { promptHeldBySource: request.promptHeldBySource } - : {}) + ...(request.onPromptDelivered ? { onPromptDelivered: request.onPromptDelivered } : {}) }) } diff --git a/src/renderer/src/lib/fix-checks-agent-launch.test.ts b/src/renderer/src/lib/fix-checks-agent-launch.test.ts index 7f22be3c85ba..32c636f13f7c 100644 --- a/src/renderer/src/lib/fix-checks-agent-launch.test.ts +++ b/src/renderer/src/lib/fix-checks-agent-launch.test.ts @@ -367,29 +367,4 @@ describe('startFixChecksAgent', () => { expect(mocks.focusTerminalTabSurface).not.toHaveBeenCalled() }) - - // A structured chat's first message starts its agent: "Started" is said only once it took the - // prompt, so a start that failed (or is still retrying) answers no here. - it.each([ - [true, true], - [false, false] - ])( - 'answers the attached workspace launch by its prompt delivery (delivered: %s)', - async (delivered, started) => { - mocks.launchAgentInNewTab.mockReturnValue({ - surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'session-1' }, - promptDeliveryResult: Promise.resolve({ delivered, failureNotified: false }) - }) - const { startFixChecksAgent } = await import('./fix-checks-agent-launch') - - await expect( - startFixChecksAgent({ - repoId: 'repo-1', - worktreeId: 'wt-1', - basePrompt: 'Fix checks', - launchSource: 'task_page' - }) - ).resolves.toBe(started) - } - ) }) diff --git a/src/renderer/src/lib/fix-checks-agent-launch.ts b/src/renderer/src/lib/fix-checks-agent-launch.ts index d3c2f9be372d..1bfa1b78253f 100644 --- a/src/renderer/src/lib/fix-checks-agent-launch.ts +++ b/src/renderer/src/lib/fix-checks-agent-launch.ts @@ -4,7 +4,6 @@ import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { findGithubPrWorkspaceAttachment } from '@/lib/github-work-item-workspace-attachment' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import { launchWorkItemDirect } from '@/lib/launch-work-item-direct' -import { launchPromptDelivered } from '@/lib/launch-prompt-delivered' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { CLIENT_PLATFORM } from '@/lib/new-workspace' import { planAgentCliArgsSuffix } from '@/lib/tui-agent-startup' @@ -197,7 +196,6 @@ export async function startFixChecksAgent(args: StartFixChecksAgentArgs): Promis promptDelivery: 'submit-after-ready', launchPlatform, launchSource: args.launchSource, - promptHeldBySource: true, beforeSurfaceOpen: () => { // Why: the launcher owns the initial surface, so revealing must not seed a sibling shell. const revealed = activateAndRevealWorktree(targetWorktreeId, { @@ -224,8 +222,7 @@ export async function startFixChecksAgent(args: StartFixChecksAgentArgs): Promis if (result.surface.kind === 'local-terminal') { focusTerminalTabSurface(result.surface.tabId) } - // Callers say "Started an AI agent" on true: only once the agent took the prompt. - return launchPromptDelivered(result) + return true } if (!args.item || !args.openModalFallback) { @@ -249,7 +246,6 @@ export async function startFixChecksAgent(args: StartFixChecksAgentArgs): Promis launchSource: args.launchSource, telemetrySource: args.telemetrySource, promptDelivery: 'submit-after-ready', - waitForPromptDelivery: true, agentArgs: recipe.agentArgs, ...(agentOverride.kind === 'agent' ? { agentOverride: agentOverride.agent } : {}), openModalFallback: args.openModalFallback diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 8c86f007e885..ce28d3c8ad18 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -52,10 +52,6 @@ export type LaunchAgentInNewTabArgs = { launchPlatform?: NodeJS.Platform /** Called after the prompt is actually delivered to the agent input path. */ onPromptDelivered?: () => void - /** The caller keeps the prompt until `promptDeliveryResult` says it went, and sends it again - * itself; a structured chat then offers it no Retry of its own. Defaults to whether the caller - * passes `onPromptDelivered`; `false` is one told of delivery only to say so. */ - promptHeldBySource?: boolean /** * Called before `onPromptDelivered` when the paste was written without ever observing the * agent's composer, so the launch cannot claim the prompt arrived. Fires only on the @@ -119,7 +115,6 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent launchPlatform, onPromptDelivered, onPromptDeliveryUnconfirmed, - promptHeldBySource, agentSessionLaunchPlan, pendingActivationSpawn, beforeSurfaceOpen @@ -214,8 +209,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent promptDelivery: viewModePromptDelivery, tuiCustomization: { cwd: initialCwd }, initialSessionOptions: startupPlan.sessionOptions, - onPromptDelivered, - ...(promptHeldBySource !== undefined ? { promptHeldBySource } : {}) + onPromptDelivered }) if (plan?.route === 'structured-native-chat') { const structured = launchAgentInStructuredNewTab({ diff --git a/src/renderer/src/lib/launch-agent-session-continuation.test.ts b/src/renderer/src/lib/launch-agent-session-continuation.test.ts index b26a81c88770..0634680cf520 100644 --- a/src/renderer/src/lib/launch-agent-session-continuation.test.ts +++ b/src/renderer/src/lib/launch-agent-session-continuation.test.ts @@ -75,9 +75,7 @@ describe('launchAgentSessionContinuation', () => { worktreeId: 'wt-1', groupId: 'group-1', initialCwd: '/repo/worktree/packages/app', - promptDelivery: 'draft', - // Its dialog keeps no copy: a chat's own Retry, not this launch, sends it again. - promptHeldBySource: false + promptDelivery: 'draft' }) ) }) diff --git a/src/renderer/src/lib/launch-agent-session-continuation.ts b/src/renderer/src/lib/launch-agent-session-continuation.ts index 38c4645ade1e..5656eddca040 100644 --- a/src/renderer/src/lib/launch-agent-session-continuation.ts +++ b/src/renderer/src/lib/launch-agent-session-continuation.ts @@ -91,8 +91,6 @@ export async function launchAgentSessionContinuation({ promptDelivery: agent === 'claude' ? 'draft' : 'submit-after-ready', launchSource, ...(initialCwd ? { initialCwd } : {}), - // Its dialog keeps no copy once launched, so the chat's own Retry is how it is sent again. - promptHeldBySource: false, onPromptDeliveryUnconfirmed: () => { deliveryUnconfirmed = true }, diff --git a/src/renderer/src/lib/launch-prompt-delivered.test.ts b/src/renderer/src/lib/launch-prompt-delivered.test.ts deleted file mode 100644 index a2ebffcb7d84..000000000000 --- a/src/renderer/src/lib/launch-prompt-delivered.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { launchPromptDelivered } from './launch-prompt-delivered' - -describe('launchPromptDelivered', () => { - it('counts a launch with no prompt delivery to wait on as started', async () => { - await expect(launchPromptDelivered({})).resolves.toBe(true) - }) - - it('answers by the delivery, and a delivery that failed outright as not delivered', async () => { - await expect( - launchPromptDelivered({ promptDeliveryResult: Promise.resolve({ delivered: true }) }) - ).resolves.toBe(true) - await expect( - launchPromptDelivered({ promptDeliveryResult: Promise.resolve({ delivered: false }) }) - ).resolves.toBe(false) - await expect( - launchPromptDelivered({ promptDeliveryResult: Promise.reject(new Error('lost')) }) - ).resolves.toBe(false) - }) -}) diff --git a/src/renderer/src/lib/launch-prompt-delivered.ts b/src/renderer/src/lib/launch-prompt-delivered.ts deleted file mode 100644 index 19774bf5a077..000000000000 --- a/src/renderer/src/lib/launch-prompt-delivered.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Whether a launch's prompt reached its agent, for a caller that says "Started an AI agent" only -// then. A structured chat's first message starts its agent, so the tab existing proves nothing; a -// launch with no prompt delivery to wait on counts as started when its surface exists. - -type LaunchWithPromptDelivery = { - promptDeliveryResult?: Promise<{ delivered: boolean }> -} - -export async function launchPromptDelivered(launch: LaunchWithPromptDelivery): Promise { - if (!launch.promptDeliveryResult) { - return true - } - return launch.promptDeliveryResult.then( - (result) => result.delivered, - () => false - ) -} diff --git a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts index 110a4d74356b..cbf080f12565 100644 --- a/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts +++ b/src/renderer/src/lib/launch-work-item-direct-agent-routing.ts @@ -7,7 +7,6 @@ import type { AgentSessionLaunchPlan } from '@/lib/agent-session-launch-plan' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' import { beginStructuredAgentSessionProvisionalLaunch } from '@/lib/structured-agent-session-provisional-tab' -import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-session-launch-prompt' export function buildDirectWorkItemStartup(args: { agent: TuiAgent | null @@ -87,7 +86,6 @@ export function beginDirectWorkItemStructuredLaunch(args: { completed: boolean structuredLaunch: boolean primaryTabId: string | null - promptDeliveryResult?: Promise } { const { plan } = args const notLaunched = (structuredLaunch: boolean) => ({ @@ -109,7 +107,6 @@ export function beginDirectWorkItemStructuredLaunch(args: { return { completed: true, structuredLaunch: true, - primaryTabId: launch.tab.id, - ...(launch.promptDeliveryResult ? { promptDeliveryResult: launch.promptDeliveryResult } : {}) + primaryTabId: launch.tab.id } } diff --git a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts index 861727f7ea90..96ea10d91ec2 100644 --- a/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts +++ b/src/renderer/src/lib/launch-work-item-direct-route-preparation.ts @@ -39,8 +39,6 @@ export async function prepareDirectWorkItemAgentLaunch(args: { launchPlatform?: NodeJS.Platform repoProjectRuntime?: Parameters[0]['repoProjectRuntime'] planLaunch: typeof planAgentSessionLaunch - /** The caller waits on the prompt's delivery and keeps it until then. */ - promptHeldBySource?: boolean }): Promise { const launchConnectionId = getConnectionId(args.worktreeId) ?? args.repoConnectionId const agentSelection = await resolveDirectWorkItemAgent({ @@ -99,8 +97,7 @@ export async function prepareDirectWorkItemAgentLaunch(args: { workspace: { kind: 'git-worktree', worktreeId: args.worktreeId, repoId: args.repoId }, prompt: args.draftContent, promptDelivery: args.promptDelivery, - initialSessionOptions: startupPlan?.sessionOptions, - ...(args.promptHeldBySource ? { promptHeldBySource: true } : {}) + initialSessionOptions: startupPlan?.sessionOptions }) const structuredLaunch = plan?.route === 'structured-native-chat' diff --git a/src/renderer/src/lib/launch-work-item-direct-types.ts b/src/renderer/src/lib/launch-work-item-direct-types.ts index a2b320899cff..4d68b4d36c33 100644 --- a/src/renderer/src/lib/launch-work-item-direct-types.ts +++ b/src/renderer/src/lib/launch-work-item-direct-types.ts @@ -32,7 +32,4 @@ export type LaunchWorkItemDirectArgs = { agentArgs?: string | null promptDelivery?: 'draft' | 'submit-after-ready' launchPlatform?: NodeJS.Platform - /** Answer only once a structured chat's agent took the prompt, for a caller that then says it - * started; otherwise the launch counts once its chat tab exists. */ - waitForPromptDelivery?: boolean } diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index 5e8efa3938aa..5b6686774bc0 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -586,47 +586,6 @@ describe('launchWorkItemDirect', () => { expect(mocks.seedNativeChatLaunchDraft).not.toHaveBeenCalled() }) - // A caller that then says "Started an AI agent" waits for the chat's agent to take the prompt; - // others count the launch once its chat tab exists. - it.each([ - [true, false, false], - [true, true, true], - [false, false, true] - ])( - 'answers a structured launch by its prompt delivery when asked to wait (wait: %s, delivered: %s)', - async (waitForPromptDelivery, delivered, answer) => { - mocks.ensureDetectedAgents.mockResolvedValue(['claude']) - vi.mocked(beginDirectWorkItemStructuredLaunch).mockImplementationOnce((launch) => { - launch.beforeOpen('session-1') - return { - completed: true, - structuredLaunch: true, - primaryTabId: 'chat-tab', - promptDeliveryResult: Promise.resolve({ delivered, failureNotified: false }) - } - }) - const { launchWorkItemDirect } = await import('./launch-work-item-direct') - - await expect( - launchWorkItemDirect({ - repoId: 'repo-1', - launchSource: 'task_page', - openModalFallback: vi.fn(), - agentOverride: 'claude', - promptDelivery: 'submit-after-ready', - waitForPromptDelivery, - item: { - type: 'pr', - number: 7, - title: 'Review this PR', - url: 'https://github.com/acme/repo/pull/7', - pasteContent: 'Fix the checks.' - } - }) - ).resolves.toBe(answer) - } - ) - it('uses remote cursor-agent detection and paste launch for SSH repos', async () => { mocks.store.repos = [ { diff --git a/src/renderer/src/lib/launch-work-item-direct.ts b/src/renderer/src/lib/launch-work-item-direct.ts index cb200a157f7a..6b027e8303e3 100644 --- a/src/renderer/src/lib/launch-work-item-direct.ts +++ b/src/renderer/src/lib/launch-work-item-direct.ts @@ -31,7 +31,6 @@ import { resolveDirectSetupDecision } from '@/lib/launch-work-item-direct-preflight' import type { LaunchWorkItemDirectArgs } from '@/lib/launch-work-item-direct-types' -import { launchPromptDelivered } from '@/lib/launch-prompt-delivered' import { resolveSourceControlLaunchPlatform } from '@/lib/source-control-launch-platform' import { getSettingsForRepoRuntimeOwner } from '@/lib/repo-runtime-owner' import { getLocalRepoProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' @@ -167,7 +166,6 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom let draftLaunchedNatively = false let plan: AgentSessionLaunchPlan | null = null let structuredLaunchCompleted = false - let structuredLaunch: ReturnType | null = null const draftContent = await getDirectWorkItemDraftContent(item, repoConnectionId) let startupPlanFailed = false try { @@ -216,8 +214,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom promptDelivery, launchPlatform: args.launchPlatform, repoProjectRuntime, - planLaunch: planAgentSessionLaunch, - ...(args.waitForPromptDelivery ? { promptHeldBySource: true } : {}) + planLaunch: planAgentSessionLaunch }) if (launchPreparation.unavailable) { activateAndRevealWorktree(worktreeId, { @@ -268,7 +265,6 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom return false } structuredLaunchCompleted = structuredResult.completed - structuredLaunch = structuredResult primaryTabId = structuredResult.completed ? structuredResult.primaryTabId : activation.primaryTabId @@ -281,9 +277,7 @@ export async function launchWorkItemDirect(args: LaunchWorkItemDirectArgs): Prom store.setSidebarOpen(true) if (structuredLaunchCompleted) { - return args.waitForPromptDelivery && structuredLaunch - ? launchPromptDelivered(structuredLaunch) - : true + return true } if (startupPlanFailed) { diff --git a/src/renderer/src/lib/structured-agent-session-launch-callers.ts b/src/renderer/src/lib/structured-agent-session-launch-callers.ts index 92492527df0f..db3f930d0fbe 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-callers.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-callers.ts @@ -1,5 +1,4 @@ import { settleStructuredAgentLaunchPrompt } from '@/lib/structured-agent-session-launch-prompt' -import { enqueueStructuredAgentSessionLaunchPrompt } from '@/components/native-chat/structured-agent-session-outbox-storage' import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-session-launch-prompt' import type { StructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox' import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' @@ -8,26 +7,11 @@ export type StructuredAgentLaunchOptions = { prompt?: string promptDelivery?: 'auto-submit' | 'submit-after-ready' | 'draft' onPromptDelivered?: () => void - /** The caller keeps the prompt, or a side effect, until `promptDeliveryResult` says it went, so - * it is where a failed prompt is sent again. Defaults to whether it passes `onPromptDelivered`; - * `false` is a caller told of delivery only to say so, which keeps nothing to send again. */ - promptHeldBySource?: boolean /** Adopt an existing provider conversation instead of starting a fresh one. Part of the launch's * identity, not a preference — see `launchIdentity`. */ resumeFrom?: StructuredAgentSessionResumeSource } -/** Queues a launch's prompt on its chat's outbox. A caller with a delivery consumer keeps the - * prompt until it hears it went, so the entry is marked as held by it. */ -export function stageStructuredLaunchPrompt( - sessionId: string, - text: string, - options: StructuredAgentLaunchOptions -): StructuredAgentSessionOutboxEntry | null { - const heldBySource = options.promptHeldBySource ?? options.onPromptDelivered !== undefined - return text ? enqueueStructuredAgentSessionLaunchPrompt(sessionId, text, heldBySource) : null -} - export type StructuredLaunchCaller = { promptDeliveryResult?: Promise } diff --git a/src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts b/src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts index 6bab0a084db4..3a82f0b390c2 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts @@ -176,35 +176,3 @@ describe('coalesced launch delivery mode', () => { ) }) }) - -describe("who holds a launch's prompt", () => { - beforeEach(() => { - vi.clearAllMocks() - localStorage.clear() - mocks.launch.mockImplementation(() => new Promise(() => {})) - }) - - it.each([ - ['a composer, which keeps nothing once the chat has it', {}, undefined], - ['a caller told of its delivery', { onPromptDelivered: () => {} }, true], - ['a caller that waits on its delivery', { promptHeldBySource: true }, true], - [ - 'a caller told of its delivery only to say so', - { onPromptDelivered: () => {}, promptHeldBySource: false }, - undefined - ] - ] as const)('marks it held only by %s', (named, caller, heldBySource) => { - const sessionId = `held-${named.replace(/\W+/g, '-')}` - mocks.createIntent.mockReturnValueOnce(launchIntent(`wt-${sessionId}`, sessionId)) - - startStructuredAgentLaunch(`wt-${sessionId}`, 'codex', { - prompt: 'review this', - promptDelivery: 'auto-submit', - ...caller - }) - - const [entry] = readOutbox(sessionId) - expect(entry).toMatchObject({ source: 'launch' }) - expect(entry?.heldBySource).toBe(heldBySource) - }) -}) diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt-handover.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt-handover.ts deleted file mode 100644 index e941f4b6b30b..000000000000 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt-handover.ts +++ /dev/null @@ -1,92 +0,0 @@ -// When a launch prompt counts as delivered: once the agent has taken it. A chat's first message -// starts its agent, and the host answers the send when it accepts the message, before that start; -// a caller that writes outward on delivery (GitHub replies, cleared notes) must not do so for an -// agent that never started. So the wait follows the message to its own final state, read from the -// session's publication: no new wire field, and no timer of its own. A start the host retries -// keeps the message queued, and the retry schedule ends in a verdict. - -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import { subscribeStructuredAgentSession } from '@/runtime/structured-agent-session-client' - -type LaunchPromptVerdict = 'taken' | 'not-taken' | 'waiting' - -export function structuredLaunchPromptVerdict( - submission: Pick -): LaunchPromptVerdict { - switch (submission.dispatchState) { - case 'accepted': - return 'taken' - // Unknown is a hand-over whose answer was lost, which a late echo can still prove taken. - case 'pending': - case 'unknown': - return 'waiting' - // A start that failed for good, or a chat closed first. - case 'rejected': - return 'not-taken' - } -} - -// One read per launch prompt, whoever asks. -const inFlight = new Map>() - -/** Whether the chat's agent took the launch prompt: only accepted or rejected answer it. Anything - * that ends the read first (the stream ending or failing) answers no: the caller keeps what it - * would have written. */ -export function awaitStructuredLaunchPromptTaken( - sessionId: string, - clientMessageId: string -): Promise { - const key = `${sessionId}:${clientMessageId}` - const existing = inFlight.get(key) - if (existing) { - return existing - } - const read = readUntilFinal(sessionId, clientMessageId).finally(() => inFlight.delete(key)) - inFlight.set(key, read) - return read -} - -function readUntilFinal(sessionId: string, clientMessageId: string): Promise { - return new Promise((resolve) => { - let settled = false - let unsubscribe: (() => void) | null = null - const finish = (taken: boolean): void => { - if (settled) { - return - } - settled = true - unsubscribe?.() - resolve(taken) - } - const read = (submissions: readonly AgentJournalSubmission[]): void => { - const submission = submissions.find((entry) => entry.clientMessageId === clientMessageId) - const verdict = submission ? structuredLaunchPromptVerdict(submission) : 'waiting' - if (verdict !== 'waiting') { - finish(verdict === 'taken') - } - } - subscribeStructuredAgentSession( - { kind: 'local' }, - { sessionId }, - (event) => { - if (event.type === 'snapshot' || event.type === 'reset') { - read(event.page.submissions) - } else if (event.type === 'batch') { - read(event.batch.submissions) - } else { - finish(false) - } - }, - () => finish(false), - () => finish(false) - ).then( - (handle) => { - unsubscribe = handle.unsubscribe - if (settled) { - handle.unsubscribe() - } - }, - () => finish(false) - ) - }) -} diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt-test-support.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt-test-support.ts deleted file mode 100644 index 70d3459ca3ae..000000000000 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt-test-support.ts +++ /dev/null @@ -1,150 +0,0 @@ -// A new chat's first message as the host answers and then publishes it, for tests of what a launch -// prompt's caller does on delivery. The caller's test mocks `@/runtime/structured-agent-session-client` -// with the two spies handed in here. - -import { vi, type Mock } from 'vitest' -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' -import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' - -export type LaunchPromptClientMock = { call: Mock; subscribe: Mock } - -type FirstMessage = Omit - -export type FirstMessageChange = Partial - -const QUEUED: FirstMessage = { - fence: 1, - payloadFingerprint: 'fingerprint', - dispatchState: 'pending', - providerItemId: null, - reason: null, - submittedAt: 1, - resolvedAt: null, - handoverRecorded: true -} - -const RETRYING = (attempts: number): Partial => ({ - startRetry: { - attempts, - reason: 'An account switch is in progress.', - rejection: { kind: 'accountSwitchInProgress' }, - failedAt: attempts, - nextAttemptAt: attempts + 15_000 - } -}) - -/** What becomes of a first message whose agent's first start fails. */ -export const FIRST_START_FAILS = { - /** The retry starts the agent, which takes the message. */ - retriedThenTaken: [ - RETRYING(1), - { handedOverAt: 20_000 }, - { dispatchState: 'accepted', resolvedAt: 20_001 } - ], - /** The first hand-over's answer is lost, and a late echo proves the agent took it. */ - unknownThenTaken: [ - { handedOverAt: 20_000 }, - { dispatchState: 'unknown' }, - { dispatchState: 'accepted' } - ], - /** Every start fails; the last try rejects the message. */ - rejectedAfterTries: [ - RETRYING(1), - RETRYING(2), - RETRYING(3), - { dispatchState: 'rejected', rejection: { kind: 'accountSwitchInProgress' } } - ], - /** The person closes the chat while it waits: the host withdraws the message, keeping the start - * failure it waited out (structured-agent-session-close-withdraws-first-message.test.ts). */ - chatClosed: [ - RETRYING(1), - { dispatchState: 'rejected', rejection: { kind: 'accountSwitchInProgress' } } - ] -} satisfies Record[]> - -export type FirstMessageStream = { - /** Publishes the message's next state, as a journal batch. */ - next: (change: Partial) => void - /** Whether the reader of the stream is still subscribed. */ - open: () => boolean -} - -/** - * Answers the send as accepted-but-queued and hands back the stream the host then publishes: a - * snapshot first, as the host's subscribe always opens, then batches. `opened` is what the message - * already is when the reader subscribes, e.g. taken in the gap between the send's answer and then. - */ -export function firstMessageStream( - client: LaunchPromptClientMock, - clientMessageId: string, - opened: Partial = {} -): Promise { - const answered: AgentJournalSubmission = { clientMessageId, ...QUEUED } - let current: AgentJournalSubmission = { ...answered, ...opened } - client.call.mockResolvedValue({ - ok: true, - replayed: false, - fence: 1, - cursor: { epoch: 'epoch-1', sequence: 1 }, - value: { clientMessageId, submission: answered } - }) - return new Promise((resolve) => { - client.subscribe.mockImplementation( - async ( - _target: unknown, - _params: unknown, - onEvent: (e: AgentSessionSubscribeEvent) => void - ) => { - let subscribed = true - const publish = (submission: AgentJournalSubmission): void => - onEvent({ - type: 'batch', - sessionId: 'session-1', - batch: { - cursor: { epoch: 'epoch-1', sequence: 2 }, - items: [], - removedItemIds: [], - submissions: [submission] - } - }) - queueMicrotask(() => { - onEvent({ - type: 'snapshot', - sessionId: 'session-1', - fence: 2, - page: { - sessionId: 'session-1', - epoch: 'epoch-1', - direction: 'tail', - items: [], - removedItemIds: [], - submissions: [current], - window: { oldest: null, newest: null, nextCursor: { epoch: 'epoch-1', sequence: 2 } }, - hasOlder: false, - hasNewer: false - } - }) - resolve({ - next: (change) => { - current = { ...current, ...change } - publish(current) - }, - open: () => subscribed - }) - }) - return { - unsubscribe: vi.fn(() => { - subscribed = false - }) - } - } - ) - }) -} - -/** Plays a whole outcome on the stream. */ -export function play(stream: FirstMessageStream, changes: readonly Partial[]): void { - for (const change of changes) { - stream.next(change) - } -} diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts index d0335c57e4f4..6945c0456454 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts @@ -3,37 +3,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { enqueueStructuredAgentSessionLaunchPrompt, - getStructuredAgentSessionOutbox, mutateStructuredAgentSessionLaunchPrompt } from '@/components/native-chat/structured-agent-session-outbox-storage' -import { structuredAgentSessionDeliveryNotices } from '@/components/native-chat/structured-agent-session-delivery-notices' -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' -const mocks = vi.hoisted(() => ({ call: vi.fn(), subscribe: vi.fn() })) +const mocks = vi.hoisted(() => ({ call: vi.fn() })) vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: mocks.call, - subscribeStructuredAgentSession: mocks.subscribe + callStructuredAgentSession: mocks.call })) import { settleStructuredAgentLaunchPrompt } from './structured-agent-session-launch-prompt' -import { awaitStructuredLaunchPromptTaken } from './structured-agent-session-launch-prompt-handover' -import { - FIRST_START_FAILS, - firstMessageStream, - play -} from './structured-agent-session-launch-prompt-test-support' - -function launch(onPromptDelivered = vi.fn()) { - const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'review this') - const stream = firstMessageStream(mocks, stagedEntry!.clientMessageId) - const result = settleStructuredAgentLaunchPrompt({ - launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), - options: { prompt: 'review this', onPromptDelivered }, - stagedEntry - }) - return { stagedEntry, stream, result: result!, onPromptDelivered } -} describe('settleStructuredAgentLaunchPrompt', () => { beforeEach(() => { @@ -44,99 +23,42 @@ describe('settleStructuredAgentLaunchPrompt', () => { ) }) - it('reports a launch prompt delivered only once the agent a retried start brought up takes it', async () => { - const { stream, result, onPromptDelivered } = launch() - const host = await stream - const [retry, handedOver, accepted] = FIRST_START_FAILS.retriedThenTaken - - host.next(retry!) - host.next(handedOver!) - await Promise.resolve() - // Still waiting: nothing outward has happened, and the entry waits for the provider echo. - expect(onPromptDelivered).not.toHaveBeenCalled() - const persisted = JSON.parse(localStorage.getItem(localStorage.key(0)!) ?? '[]') as { - state: string - }[] - expect(persisted).toMatchObject([{ state: 'dispatching' }]) - - host.next(accepted!) - await expect(result).resolves.toEqual({ delivered: true, failureNotified: false }) - expect(onPromptDelivered).toHaveBeenCalledOnce() - expect(host.open()).toBe(false) - }) - - it.each([ - ['rejected after its tries', FIRST_START_FAILS.rejectedAfterTries], - ['withdrawn when its chat closes mid-wait', FIRST_START_FAILS.chatClosed] - ])('reports a launch prompt %s undelivered', async (_case, outcome) => { - const { stream, result, onPromptDelivered } = launch() - play(await stream, outcome) - - await expect(result).resolves.toEqual({ delivered: false, failureNotified: false }) - expect(onPromptDelivered).not.toHaveBeenCalled() - }) - - it('keeps waiting through an unknown answer that a late echo then proves taken', async () => { - const { stream, result, onPromptDelivered } = launch() - const host = await stream - const [handedOver, unknown, accepted] = FIRST_START_FAILS.unknownThenTaken - host.next(handedOver!) - host.next(unknown!) - await Promise.resolve() - expect(onPromptDelivered).not.toHaveBeenCalled() - - host.next(accepted!) - await expect(result).resolves.toEqual({ delivered: true, failureNotified: false }) - expect(onPromptDelivered).toHaveBeenCalledOnce() - }) - - // The host's subscribe opens with a snapshot, which already carries a verdict reached in the gap - // between the send's answer and the read. - it.each([ - ['taken', { dispatchState: 'accepted' as const }, true], - ['rejected', { dispatchState: 'rejected' as const, rejection: { kind: 'notSignedIn' } }, false] - ])('answers a first message already %s when the read opens', async (state, opened, delivered) => { - // Its own session, so no other case's read can answer for it. - const sessionId = `session-opened-${state}` - const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt(sessionId, 'review this') - const onPromptDelivered = vi.fn() - void firstMessageStream(mocks, stagedEntry!.clientMessageId, opened) - - await expect( - settleStructuredAgentLaunchPrompt({ - launchResult: Promise.resolve({ sessionId, fence: 1 }), - options: { prompt: 'review this', onPromptDelivered }, - stagedEntry - }) - ).resolves.toEqual({ delivered, failureNotified: false }) - expect(onPromptDelivered).toHaveBeenCalledTimes(delivered ? 1 : 0) - }) - - it('reports undelivered when the session stream fails before a verdict', async () => { + it('reports an admitted launch prompt delivered while retaining it for the provider echo', async () => { const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'review this') - void firstMessageStream(mocks, stagedEntry!.clientMessageId) - mocks.subscribe.mockImplementation(async (_target, _params, _onEvent, onError) => { - queueMicrotask(() => onError(new Error('stream closed'))) - return { unsubscribe: vi.fn() } + const onPromptDelivered = vi.fn() + mocks.call.mockResolvedValue({ + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value: { + clientMessageId: stagedEntry!.clientMessageId, + submission: { + clientMessageId: stagedEntry!.clientMessageId, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null + } + } }) await expect( settleStructuredAgentLaunchPrompt({ launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), - options: { prompt: 'review this' }, + options: { prompt: 'review this', onPromptDelivered }, stagedEntry }) - ).resolves.toEqual({ delivered: false, failureNotified: false }) - }) - - it('reads one launch prompt once, however many ask', async () => { - const stream = firstMessageStream(mocks, 'message-1') - const first = awaitStructuredLaunchPromptTaken('session-1', 'message-1') - const second = awaitStructuredLaunchPromptTaken('session-1', 'message-1') - play(await stream, FIRST_START_FAILS.retriedThenTaken) + ).resolves.toEqual({ delivered: true, failureNotified: false }) - await expect(Promise.all([first, second])).resolves.toEqual([true, true]) - expect(mocks.subscribe).toHaveBeenCalledOnce() + expect(onPromptDelivered).toHaveBeenCalledOnce() + const persisted = JSON.parse(localStorage.getItem(localStorage.key(0)!) ?? '[]') as { + state: string + }[] + expect(persisted).toMatchObject([{ state: 'dispatching' }]) }) it('drops the previous attempt failure when the launch path sends the message again', async () => { @@ -166,38 +88,4 @@ describe('settleStructuredAgentLaunchPrompt', () => { expect.objectContaining({ lastFailure: expect.anything() }) ) }) - - it('keeps why a launch prompt its caller holds was refused for good, so its notice says it', async () => { - const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'review this', true) - mocks.call.mockResolvedValue({ - ok: false, - refusal: { code: 'agent_session_operation_invalid', message: 'invalid' } - }) - - await settleStructuredAgentLaunchPrompt({ - launchResult: Promise.resolve({ sessionId: 'session-1', fence: 1 }), - options: { prompt: 'review this' }, - stagedEntry - }) - - const [refused] = getStructuredAgentSessionOutbox('session-1') - expect(refused).toMatchObject({ - state: 'rejected', - lastFailure: { kind: 'refused', code: 'agent_session_operation_invalid' } - }) - const notice = structuredAgentSessionDeliveryNotices( - [refused!], - 'Claude', - vi.fn(), - [], - [], - new Set() - ) - expect(notice.get(agentJournalSubmissionKey(refused!.clientMessageId))?.text).not.toContain( - 'Message was not sent.' - ) - }) - - // Whatever the start's failure (signed out, not installed, the agent exiting as it started), it - // ends as the message's rejection; nothing that writes on delivery may run. }) diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index 534dc11cfa35..0caaafbc6f40 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -2,7 +2,6 @@ import type { AgentSessionMutationResult, AgentSessionSendResult } from '../../../shared/agent-session-wire' -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { requeueStructuredAgentSessionSendRefusal, stageStructuredAgentSessionOutboxEntryForSend, @@ -17,10 +16,6 @@ import { } from '@/components/native-chat/structured-agent-session-outbox-storage' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { createBrowserUuid } from '@/lib/browser-uuid' -import { - awaitStructuredLaunchPromptTaken, - structuredLaunchPromptVerdict -} from '@/lib/structured-agent-session-launch-prompt-handover' export type StructuredPromptDeliveryResult = { delivered: boolean @@ -100,13 +95,9 @@ function mutateEntry( ) } -/** How the host first answered an admitted launch prompt: the message, or held as a queued draft. */ -type LaunchPromptAnswer = AgentJournalSubmission | 'queued' - async function dispatchStructuredLaunchPrompt( entry: StructuredAgentSessionOutboxEntry, - receipt: LaunchReceipt, - onAnswer: (answer: LaunchPromptAnswer) => void + receipt: LaunchReceipt ): Promise { // Why: an unsaved stage must leave the entry queued; a held 'dispatching' copy is never drained. if ( @@ -127,26 +118,21 @@ async function dispatchStructuredLaunchPrompt( structuredAgentSessionSendRequest(entry, receipt.fence) ) if (!result.ok) { - const failure = agentSessionRefusalFailure(result.refusal) - mutateEntry(entry, (current) => { - const requeued = requeueStructuredAgentSessionSendRefusal( + mutateEntry(entry, (current) => + requeueStructuredAgentSessionSendRefusal( current, - failure, + agentSessionRefusalFailure(result.refusal), () => createStructuredAgentSessionOperationId(createBrowserUuid), entry.lastAttemptAt !== null ) - // Refused for good, the chat shows it as not sent, so it says why. - return requeued.state === 'rejected' ? { ...requeued, lastFailure: failure } : requeued - }) + ) return false } if ('queued' in result.value) { // The host holds the draft; the outbox entry is spent. mutateEntry(entry, () => null) - onAnswer('queued') return true } - onAnswer(result.value.submission) const dispatchState = result.value.submission.dispatchState mutateEntry(entry, (current) => dispatchState === 'accepted' @@ -183,31 +169,13 @@ export function settleStructuredAgentLaunchPrompt(args: { return { delivered: false, failureNotified: true } } const entry = args.stagedEntry - let answer: LaunchPromptAnswer | undefined const dispatch = shareStructuredAgentLaunchPromptDispatch( entry.sessionId, entry.clientMessageId, receipt.fence, - () => - dispatchStructuredLaunchPrompt(entry, receipt, (answered) => { - answer = answered - }) + () => dispatchStructuredLaunchPrompt(entry, receipt) ) - // Admitted is not delivered: the chat's first message starts its agent, which may fail, so - // a pending answer waits for the message's own final state. A queued draft keeps its old - // meaning; a dispatch the chat's own outbox ran answered elsewhere. - // An unknown answer is a hand-over nobody can vouch for yet, still owed its final state. - const admitted = - (await dispatch.promise) || - (answer !== undefined && answer !== 'queued' && answer.dispatchState === 'unknown') - const verdict = - answer === undefined || answer === 'queued' ? null : structuredLaunchPromptVerdict(answer) - const delivered = - admitted && - (answer === 'queued' || - verdict === 'taken' || - (verdict !== 'not-taken' && - (await awaitStructuredLaunchPromptTaken(entry.sessionId, entry.clientMessageId)))) + const delivered = await dispatch.promise if (delivered) { args.options.onPromptDelivered?.() } diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index 6356d6f04218..10a37d46c4c3 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -6,7 +6,10 @@ import { retryStructuredAgentSessionLaunchIntent, StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' -import { discardStructuredAgentSessionLaunchOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage' +import { + discardStructuredAgentSessionLaunchOutbox, + enqueueStructuredAgentSessionLaunchPrompt +} from '@/components/native-chat/structured-agent-session-outbox-storage' import { launchAndReconcile, reconcileUnknownLaunch, @@ -18,7 +21,6 @@ import { createStructuredLaunchCallerGroup, releaseStructuredLaunchCallerAfterUnknownOutcome, settleStructuredLaunchCallers, - stageStructuredLaunchPrompt, structuredLaunchCallersHavePendingWork, type StructuredAgentLaunchOptions, type StructuredLaunchCaller @@ -205,7 +207,9 @@ function structuredAgentLaunchState( const joined = joinLaunchDelivery(options, existing.promptDelivery) // Why: failed launches keep their draft/outbox, so a retry must not stage the same prompt twice. const text = retrying ? '' : outboxPromptText(joined) - const stagedPrompt = stageStructuredLaunchPrompt(existing.intent.sessionId, text, joined) + const stagedPrompt = text + ? enqueueStructuredAgentSessionLaunchPrompt(existing.intent.sessionId, text) + : null if (!retrying) { launchDraft.seedStructuredAgentLaunchDraft(existing.intent.sessionId, agent, joined) } @@ -229,7 +233,9 @@ function structuredAgentLaunchState( ? createStructuredAgentSessionLaunchIntent(worktreeId, agent, options.resumeFrom) : createStructuredAgentSessionLaunchIntent(worktreeId, agent) const text = outboxPromptText(options) - const stagedPrompt = stageStructuredLaunchPrompt(intent.sessionId, text, options) + const stagedPrompt = text + ? enqueueStructuredAgentSessionLaunchPrompt(intent.sessionId, text) + : null launchDraft.seedStructuredAgentLaunchDraft(intent.sessionId, agent, options) const callers = createStructuredLaunchCallerGroup() const state: StructuredLaunchState = { diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index 2079f662745e..88a10414db6b 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -24,7 +24,7 @@ export const AGENT_SESSION_FAILURE_COPY = { signInThenRunCommand: 'Sign in, then run /{{command}} again.', signInThenSend: 'Sign in, then send your message again.', providerMissing: "{{agent}} isn't installed.", - installFirst: 'Install it first.', + installThenRetry: 'Install it, then retry.', installThenRunCommand: 'Install it, then run /{{command}} again.', installThenSend: 'Install it, then send your message again.', historyTooLarge: "This conversation's history is too large to restore here.", diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index acac2ac12fb0..0bef9eacd96c 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -205,7 +205,7 @@ const FAILURE_SENTENCES = { joinSentences([ say('providerMissing', agent(say, context)), context.retryControl - ? say('installFirst') + ? say('installThenRetry') : context.command ? say('installThenRunCommand', { command: context.command }) : say('installThenSend') diff --git a/src/shared/structured-agent-session-outbox-source.ts b/src/shared/structured-agent-session-outbox-source.ts deleted file mode 100644 index 8c81dcf7e2dd..000000000000 --- a/src/shared/structured-agent-session-outbox-source.ts +++ /dev/null @@ -1,19 +0,0 @@ -// A launch prompt whose caller holds it (notes, review comments, a fix action): that caller keeps it -// as unsent until the agent takes it, and is where it is sent again. - -import type { - StructuredAgentSessionAttemptFailure, - StructuredAgentSessionOutboxEntry -} from './structured-agent-session-outbox' - -/** Withdrawn by a Stop, it stays as not sent rather than going to the composer as a second copy. */ -export function withdrawnFromItsSource( - entry: StructuredAgentSessionOutboxEntry, - lastFailure: StructuredAgentSessionAttemptFailure = { - kind: 'rejected', - reason: null, - rejection: { kind: 'cancelled' } - } -): StructuredAgentSessionOutboxEntry { - return { ...entry, state: 'rejected', lastFailure } -} diff --git a/src/shared/structured-agent-session-outbox-stop-withdrawal.ts b/src/shared/structured-agent-session-outbox-stop-withdrawal.ts index 846b1eed7fac..5606ae0c9ceb 100644 --- a/src/shared/structured-agent-session-outbox-stop-withdrawal.ts +++ b/src/shared/structured-agent-session-outbox-stop-withdrawal.ts @@ -1,6 +1,5 @@ import type { AgentJournalSubmission } from './agent-session-journal-types' import type { StructuredAgentSessionOutboxEntry } from './structured-agent-session-outbox' -import { withdrawnFromItsSource } from './structured-agent-session-outbox-source' import { structuredAgentSessionEntryHeldForRetry } from './structured-agent-session-outbox-admission' import { handedOffQueuedMessageIds } from './structured-agent-session-draft-hand-off' @@ -63,16 +62,14 @@ export function withdrawUnsentStructuredAgentSessionOutboxEntries( inFlightClientMessageId: string | null ): StructuredAgentSessionOutboxEntry[] { const unsent = unsentStructuredAgentSessionOutboxEntry(submissions) - return entries.flatMap((entry) => { - if ( - entry.clientMessageId === inFlightClientMessageId || - !unsent(entry) || - attemptedQueueSend(entry) - ) { - return [markedOutlivingStop(entry)] - } - return entry.heldBySource === true ? [withdrawnFromItsSource(entry)] : [] - }) + return entries + .filter( + (entry) => + entry.clientMessageId === inFlightClientMessageId || + !unsent(entry) || + attemptedQueueSend(entry) + ) + .map(markedOutlivingStop) } /** Whether a Stop has something here to withdraw: a message that would still go out on its own. */ diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index a90d17d87346..e6c4e26f4bcb 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -12,7 +12,6 @@ import { } from './agent-session-refusal-retry' import type { AgentSessionMutationEnvelope } from './agent-session-wire' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' -import { withdrawnFromItsSource } from './structured-agent-session-outbox-source' import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' import { parseStructuredAgentSessionOutboxQueueFields } from './structured-agent-session-outbox-delivery' @@ -35,10 +34,6 @@ export type StructuredAgentSessionOutboxEntry = { lastAttemptAt: number | null retryAfterUnknownSubmittedAt: number | null source?: 'launch' - /** A launch prompt whose caller keeps it, or a side effect, until it is delivered (notes, a review - * reply, a fix action): that caller is where it is sent again, so the chat offers no Retry and - * a Stop gives it no composer copy. A composer's own prompt has none and keeps both. */ - heldBySource?: true /** A Stop landed after this queue send went out: only the user's Retry sends it again, never the * drain, the unconfirmed probe or an owner change, which would start a turn the user stopped. */ outlivedStop?: true @@ -192,10 +187,6 @@ export function requeueStructuredAgentSessionSendRefusal( const ownerExited = refusal.code === 'agent_session_ownership_unknown' && agentSessionOwnerVerdictAllowsFreshOperationId(refusal.details?.ownerVerdict) - // Its source sends it again as a new message, so refused for good it stays here as not sent. - if (entry.heldBySource === true && refusalSettled) { - return { ...entry, state: 'rejected' } - } if ( !(refusalSettled || ownerExited) || retainOperationId || @@ -230,8 +221,7 @@ export function reconcileStructuredAgentSessionOutbox( submission?.dispatchState === 'rejected' && classifyDispatchRejection(submission).category === 'withdrawn' ) { - const failure = structuredAgentSessionRejectedFailure(submission) - return entry.heldBySource === true ? [withdrawnFromItsSource(entry, failure)] : [] + return [] } if (submission?.dispatchState === 'pending') { if (entry.state === 'dispatching') { @@ -307,7 +297,6 @@ export function parseStructuredAgentSessionOutboxEntry( ? entry.retryAfterUnknownSubmittedAt : null, ...(entry.source === 'launch' ? { source: 'launch' as const } : {}), - ...(entry.heldBySource === true ? { heldBySource: true as const } : {}), ...parseStructuredAgentSessionOutboxQueueFields(entry), ...(lastFailure ? { lastFailure } : {}) } From e1e89b423930ccef3d4aa775a66725bc72a40c4f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:22:58 -0700 Subject: [PATCH 57/84] fix(mobile): an AI button says the prompt was sent to an agent, not that the agent started The host answers a prompted launch once it has the prompt; the agent may still be starting, and a failed start shows in its chat. --- mobile/src/components/AgentLaunchNotice.test.tsx | 6 +++--- mobile/src/session/pr-ai-triage-launch.test.ts | 16 ++++++++-------- mobile/src/session/pr-ai-triage-launch.ts | 9 +++++---- .../src/session/use-mobile-pr-ai-triage.test.ts | 2 +- .../use-mobile-commit-failure-recovery.test.ts | 2 +- 5 files changed, 18 insertions(+), 17 deletions(-) diff --git a/mobile/src/components/AgentLaunchNotice.test.tsx b/mobile/src/components/AgentLaunchNotice.test.tsx index e1b7b07efce3..0123ad1b8765 100644 --- a/mobile/src/components/AgentLaunchNotice.test.tsx +++ b/mobile/src/components/AgentLaunchNotice.test.tsx @@ -78,14 +78,14 @@ describe('AgentLaunchNotice', () => { }) it('confirms a launch that went ahead, with any host note beneath it', () => { - expect(lines({ success: 'Agent started in feature-login', warning: 'w' })).toEqual([ - { text: 'Agent started in feature-login', isError: false }, + expect(lines({ success: 'Sent to an agent in feature-login', warning: 'w' })).toEqual([ + { text: 'Sent to an agent in feature-login', isError: false }, { text: 'w', isError: false } ]) }) it('never confirms beside an error', () => { - expect(lines({ success: 'Agent started', error: 'e' })).toEqual([{ text: 'e', isError: true }]) + expect(lines({ success: 'Sent to an agent', error: 'e' })).toEqual([{ text: 'e', isError: true }]) }) it('renders nothing with nothing to say', () => { diff --git a/mobile/src/session/pr-ai-triage-launch.test.ts b/mobile/src/session/pr-ai-triage-launch.test.ts index 16c8132836f3..a1f93610db1b 100644 --- a/mobile/src/session/pr-ai-triage-launch.test.ts +++ b/mobile/src/session/pr-ai-triage-launch.test.ts @@ -4,7 +4,7 @@ import type { RpcResponse } from '../transport/types' import { AGENT_LAUNCH_UPDATE_REQUIRED_MESSAGE } from './mobile-existing-agent-launch' import { AGENT_PROMPT_NOT_SENT_MESSAGE, - agentStartedMessage, + agentPromptSentMessage, launchAgentWithPrompt, promptedLaunchNotice } from './pr-ai-triage-launch' @@ -265,7 +265,7 @@ describe('promptedLaunchNotice', () => { }) expect(promptedLaunchNotice({ kind: 'sent' })).toEqual({ succeeded: true, - success: 'Agent started', + success: 'Sent to an agent', error: null, warning: null, undeliveredPrompt: null @@ -283,7 +283,7 @@ describe('promptedLaunchNotice', () => { it('never reports the host warning on a launch that went ahead as an error', () => { expect(promptedLaunchNotice({ kind: 'sent', warning: 'arguments were ignored' })).toEqual({ succeeded: true, - success: 'Agent started', + success: 'Sent to an agent', error: null, warning: 'arguments were ignored', undeliveredPrompt: null @@ -304,16 +304,16 @@ describe('promptedLaunchNotice', () => { }) }) -describe('agentStartedMessage', () => { +describe('agentPromptSentMessage', () => { // Issue #20543: a launch from the phone must say it happened, and where. - it('names the workspace the agent started in', () => { + it('names the workspace the prompt went to', () => { expect(promptedLaunchNotice({ kind: 'sent' }, 'feature-login').success).toBe( - 'Agent started in feature-login' + 'Sent to an agent in feature-login' ) }) it('still confirms the launch when the screen knows no workspace name', () => { - expect(agentStartedMessage(null)).toBe('Agent started') - expect(agentStartedMessage(' ')).toBe('Agent started') + expect(agentPromptSentMessage(null)).toBe('Sent to an agent') + expect(agentPromptSentMessage(' ')).toBe('Sent to an agent') }) }) diff --git a/mobile/src/session/pr-ai-triage-launch.ts b/mobile/src/session/pr-ai-triage-launch.ts index 61c160b224ec..842246f86438 100644 --- a/mobile/src/session/pr-ai-triage-launch.ts +++ b/mobile/src/session/pr-ai-triage-launch.ts @@ -91,10 +91,11 @@ export async function launchAgentWithPrompt(args: { } } -/** The confirmation under an AI button, naming the workspace when the screen knows it. */ -export function agentStartedMessage(workspaceLabel: string | null | undefined): string { +/** The confirmation under an AI button, naming the workspace when the screen knows it. Said once + * the host has the prompt: the agent may still be starting, and a failed start shows in its chat. */ +export function agentPromptSentMessage(workspaceLabel: string | null | undefined): string { const label = workspaceLabel?.trim() - return label ? `Agent started in ${label}` : 'Agent started' + return label ? `Sent to an agent in ${label}` : 'Sent to an agent' } /** What the button shows after a launch; one mapping so every AI button reads the same. @@ -113,7 +114,7 @@ export function promptedLaunchNotice( case 'sent': return { succeeded: true, - success: agentStartedMessage(workspaceLabel), + success: agentPromptSentMessage(workspaceLabel), error: null, warning: result.warning ?? null, undeliveredPrompt: null diff --git a/mobile/src/session/use-mobile-pr-ai-triage.test.ts b/mobile/src/session/use-mobile-pr-ai-triage.test.ts index d3e284fe882f..7e0c0d4a5a39 100644 --- a/mobile/src/session/use-mobile-pr-ai-triage.test.ts +++ b/mobile/src/session/use-mobile-pr-ai-triage.test.ts @@ -101,7 +101,7 @@ describe('useMobilePrAiTriage', () => { await triage?.launch('resolve-conflicts', () => 'resolve the conflicts') }) expect(triage?.noticeFor('resolve-conflicts')).toEqual({ - success: 'Agent started in feature-login', + success: 'Sent to an agent in feature-login', error: null, warning: null, undeliveredPrompt: null diff --git a/mobile/src/source-control/use-mobile-commit-failure-recovery.test.ts b/mobile/src/source-control/use-mobile-commit-failure-recovery.test.ts index 157a87b7448d..9fca23d4fabd 100644 --- a/mobile/src/source-control/use-mobile-commit-failure-recovery.test.ts +++ b/mobile/src/source-control/use-mobile-commit-failure-recovery.test.ts @@ -94,7 +94,7 @@ describe('useMobileCommitFailureRecovery', () => { expect(succeeded).toBe(true) expect(recovery?.launchError).toBeNull() expect(recovery?.launchWarning).toBe('the requested arguments were ignored.') - expect(recovery?.launchSuccess).toBe('Agent started in feature-login') + expect(recovery?.launchSuccess).toBe('Sent to an agent in feature-login') // A new failure is a new launch; the old one's note does not carry over. rerender({ ...FAILURE, error: 'another hook failed' }) expect(recovery?.launchWarning).toBeNull() From 27ec99bd1254a661d6596173030264242d829948 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:42:48 -0700 Subject: [PATCH 58/84] feat(native-chat): a launch prompt's review reply runs on the host once the agent takes the message "Resolve comments with AI" resolves the selected review threads and posts Orca's "fixing" reply on the ones it can't resolve. With a chat's first message now starting its agent, that public write must wait until the agent actually takes the message, without making the button wait. The reply's targets ride the message: an optional `reviewReply` on `agentSession.send` (operation fingerprint only), kept as a host-only key on the submission row. The host derives what is owed from the chat itself: a message carrying one, accepted within 24 h, with no receipt. The writes go through the runtime's repo-scoped GitHub/GitLab methods. The receipt lives in the chat under the message's id: one status line saying the person should resolve or reply themselves when a write failed, a tombstone (nothing shown) when all went through. A run that may repeat one cut off by a restart first reads the PR and skips replies already there; GitLab resolves are idempotent. A send carrying a review reply is never held as a queued draft. --- .../agent-session-journal/journal-reducer.ts | 4 + .../journal-row-builders.ts | 6 +- .../journal-row-schema.ts | 4 + .../journal-store-contracts.ts | 3 + .../agent-session-journal/journal-store.ts | 11 + .../journal-submission-fold.ts | 6 + ...tructured-agent-session-client-delivery.ts | 11 + ...structured-agent-session-host-mutations.ts | 2 + .../structured-agent-session-host-types.ts | 3 + .../structured-agent-session-host.ts | 5 +- ...structured-agent-session-mutation-plans.ts | 13 +- ...tructured-agent-session-queued-messages.ts | 8 +- .../structured-agent-session-queued-send.ts | 1 + ...tured-agent-session-review-replies.test.ts | 486 ++++++++++++++++++ ...structured-agent-session-review-replies.ts | 156 ++++++ ...tured-agent-session-review-reply-runner.ts | 156 ++++++ .../structured-agent-session-turns.ts | 2 + .../runtime/orca-runtime-get-worktree-ps.ts | 13 +- .../structured-agent-session-runtime.ts | 2 + .../agent-session-failure-words-text.ts | 4 +- .../src/i18n/en-runtime-required.json | 3 +- src/renderer/src/i18n/locales/en.json | 3 +- src/renderer/src/i18n/locales/es.json | 3 +- src/renderer/src/i18n/locales/fr.json | 3 +- src/renderer/src/i18n/locales/ja.json | 3 +- src/renderer/src/i18n/locales/ko.json | 3 +- src/renderer/src/i18n/locales/zh.json | 3 +- src/shared/agent-session-failure-copy.ts | 4 +- src/shared/agent-session-failure-words.ts | 3 +- src/shared/agent-session-failure.ts | 7 +- src/shared/agent-session-review-reply.ts | 61 +++ .../structured-agent-session-params.ts | 5 + 32 files changed, 978 insertions(+), 19 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-review-reply-runner.ts create mode 100644 src/shared/agent-session-review-reply.ts diff --git a/src/main/native-chat/agent-session-journal/journal-reducer.ts b/src/main/native-chat/agent-session-journal/journal-reducer.ts index a7d6f150e70c..42aae15de38b 100644 --- a/src/main/native-chat/agent-session-journal/journal-reducer.ts +++ b/src/main/native-chat/agent-session-journal/journal-reducer.ts @@ -10,6 +10,7 @@ // likewise the creating write's: a revision naming no producer keeps it, one // naming any replaces it. +import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' import type { AgentJournalAcceptanceReceipt, AgentJournalRenderItem, @@ -52,6 +53,8 @@ export type JournalReducerState = { /** Revision of a removed item, so a late lower revision cannot resurrect it. */ tombstones: Map submissions: Map + /** Host-only: what Orca does on the review once the agent takes a message, by message id. */ + reviewReplies: Map receipts: Map /** Provider item id → the submission slot that adopted it. Stops an accepted * echo from appending a second copy of the user's own message. */ @@ -78,6 +81,7 @@ export function createJournalReducerState(sessionId: string, epoch: string): Jou itemFences: new Map(), tombstones: new Map(), submissions: new Map(), + reviewReplies: new Map(), receipts: new Map(), aliases: new Map(), appliedSettlementIds: new Set(), diff --git a/src/main/native-chat/agent-session-journal/journal-row-builders.ts b/src/main/native-chat/agent-session-journal/journal-row-builders.ts index 50c1edb4111f..f1d30d60b249 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-builders.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-builders.ts @@ -1,3 +1,4 @@ +import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' import type { AgentJournalItemBody, AgentJournalItemIdentity, @@ -72,6 +73,7 @@ export function journalSubmissionRowBuilder( handoverRecorded?: true queuedMessageId?: string origin?: 'client' | 'host' + reviewReply?: AgentSessionReviewReply }, /** Present when the append hands off a queued draft: the row names that draft, stamped here * from the consume itself so no hand-off path can leave the link off. */ @@ -317,6 +319,7 @@ export function buildJournalSubmissionRow(input: { handoverRecorded?: true queuedMessageId?: string origin?: 'client' | 'host' + reviewReply?: AgentSessionReviewReply }): JournalSubmissionRow { return { kind: 'submission', @@ -327,6 +330,7 @@ export function buildJournalSubmissionRow(input: { ...journalRowBase(input.state.epoch, input.seq, input.fence, input.ts), ...(input.handoverRecorded ? { handoverRecorded: true } : {}), ...(input.queuedMessageId !== undefined ? { queuedMessageId: input.queuedMessageId } : {}), - ...(input.origin !== undefined ? { origin: input.origin } : {}) + ...(input.origin !== undefined ? { origin: input.origin } : {}), + ...(input.reviewReply ? { reviewReply: input.reviewReply } : {}) } } diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema.ts b/src/main/native-chat/agent-session-journal/journal-row-schema.ts index 07ec68cedd9e..45af6469ec7e 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema.ts @@ -30,6 +30,7 @@ import { } from '../../../shared/agent-session-journal-schemas' import { isAdmissibleAgentSessionContextUsage } from '../../../shared/agent-session-context-usage-schema' import type { StructuredAgentSessionStopCause } from '../agent-session-wire/structured-agent-session-stop-cause' +import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' /** Producer linkage rides the row BASE rather than the body: the two nested * prompt shapes are `.strict()`, so an unknown key on a body would make the @@ -135,6 +136,9 @@ export type JournalSubmissionRow = JournalRowBase & { * continuation, a launch prompt, the queue's automatic drain. Absent on rows from before it * was recorded. Older readers keep the key and ignore it. */ origin?: JournalSubmissionOrigin + /** What Orca does on the review once the agent takes this message; host-only, read through + * `readAgentSessionReviewReply` as it folds. Older readers keep the key and ignore it. */ + reviewReply?: AgentSessionReviewReply } export type JournalSubmissionOrigin = 'client' | 'host' diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index 36bd9549383a..2e1b378a2a0f 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -1,4 +1,5 @@ import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' +import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' import type { AgentJournalCursor, AgentJournalItemBody, @@ -91,6 +92,8 @@ export type JournalSubmissionInput = { queuedMessageId?: string /** Who asked for this turn (`JournalSubmissionRow.origin`). */ origin?: 'client' | 'host' + /** `JournalSubmissionRow.reviewReply`. */ + reviewReply?: AgentSessionReviewReply } /** A submission append that converts a queued draft, in one transaction. */ diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index af9dbaf32adb..0ad07483a2f5 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -1,6 +1,7 @@ // Append-only journal store for one agent session. It owns the chat's fold and write queue, and no // connection: every statement goes through the host's one journal database. +import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' import { randomUUID } from 'node:crypto' import type { AgentJournalAcceptanceReceipt, @@ -265,6 +266,16 @@ export class AgentSessionJournal { submission = (clientMessageId: string) => this.state.submissions.get(clientMessageId) + /** Host-only: each message's review reply (`JournalSubmissionRow.reviewReply`). */ + reviewReplies = (): ReadonlyMap => this.state.reviewReplies + + /** The clock its rows are stamped by. */ + clock = (): number => this.now() + + /** Whether an item was ever written under this id, removed since or not. */ + itemWritten = (itemId: string): boolean => + this.state.items.has(itemId) || this.state.tombstones.has(itemId) + pendingSubmissions = (): AgentJournalSubmission[] => this.submissions().filter((entry) => entry.dispatchState === 'pending') diff --git a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts index 3f0f3cda689e..712baa95b01e 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts @@ -7,6 +7,7 @@ import { } from '../../../shared/agent-session-journal-types' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { journalRenderItem } from './journal-render-item' +import { readAgentSessionReviewReply } from '../../../shared/agent-session-review-reply' import { statedOrDerivedTurnScope, upsertJournalItem } from './journal-item-fold' import type { JournalReducerState } from './journal-reducer' import type { JournalRow } from './journal-row-schema' @@ -32,6 +33,11 @@ export function applyJournalSubmission( : {}), ...(row.origin === 'client' || row.origin === 'host' ? { origin: row.origin } : {}) }) + // A malformed stored spec is dropped, never the row; it then runs nothing. + const reviewReply = readAgentSessionReviewReply(row.reviewReply) + if (reviewReply) { + state.reviewReplies.set(row.clientMessageId, reviewReply) + } const itemId = agentJournalSubmissionKey(row.clientMessageId) // A message handed over later belongs to no turn until its handover names one. const turnScope = row.handoverRecorded diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index 8195e7c18f9b..1dca654666ab 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -10,6 +10,7 @@ import type { } from './structured-agent-session-host-types' import { AGENT_SESSION_NOT_ATTACHED } from './structured-agent-session-mutation-admission' import { StructuredAgentSessionSendSettlement } from './structured-agent-session-send-settlement' +import { createStructuredAgentSessionHostReviewReplies } from './structured-agent-session-review-replies' import { createStructuredAgentSessionHostStatusFeed, type StructuredAgentSessionStatusSubscriber @@ -27,6 +28,7 @@ export class StructuredAgentSessionClientDelivery { private readonly statusFeed private readonly turnCompletionFeed private readonly sendSettlement + private readonly reviewReplies constructor( private readonly sessions: Map, @@ -53,6 +55,10 @@ export class StructuredAgentSessionClientDelivery { this.requireJournal(sessionId) ) this.waitForSendSettlement = this.sendSettlement.wait + this.reviewReplies = createStructuredAgentSessionHostReviewReplies({ + deps, + journal: (sessionId) => sessions.get(sessionId)?.journal + }) this.subscribers = new AgentSessionSubscribers({ readCommands: (sessionId) => this.readCommands(sessionId), readQueuePublication: (sessionId) => @@ -95,6 +101,9 @@ export class StructuredAgentSessionClientDelivery { } } + /** A journal opening re-derives what a run cut off by a restart still owes. */ + observeReviewReplies = (sessionId: string): void => this.reviewReplies.observe(sessionId) + publishRestored = (sessionId: string): void => this.statusFeed.publish(sessionId, undefined, { replay: true }) @@ -131,6 +140,8 @@ export class StructuredAgentSessionClientDelivery { // subscribed, which is the whole reason a backgrounded chat can complete at all. After the // status publish, so it reads the projection that publish cached. this.turnCompletionFeed.observe(sessionId, journal) + // Off the same edge: a review reply runs once its message is accepted, read or not. + this.reviewReplies.observe(sessionId) this.onJournalActivity?.(sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts index 72160505bc7c..cbbf7d8cd8d3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-mutations.ts @@ -7,6 +7,7 @@ // admitted without the writer lease; the delivery loop starts the provider child a send needs, and // an operation only the provider can perform starts it before admission. +import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionCancelResult, @@ -55,6 +56,7 @@ export function sendStructuredAgentSessionTurn( body: AgentJournalMessageItem retryUnknown?: true delivery?: 'queue-if-active' + reviewReply?: AgentSessionReviewReply /** Host-local, set only by the client-facing `agentSession.send` RPC (the * renderer's launch prompt included): recorded as the submission's `client` * origin, whose started turn ends a Stop's or a restart's queue pause. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index aaaa595c8598..29a94c621f62 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -1,3 +1,4 @@ +import type { StructuredAgentSessionReviewRuntime } from './structured-agent-session-review-reply-runner' import type { SubmissionRejectionFact } from '../../../shared/agent-session-failure' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' @@ -150,4 +151,6 @@ export type StructuredAgentSessionHostDeps = { statusSink?: StructuredAgentSessionStatusSink /** Host model catalog surface; absent means every catalog read answers `unknown`. */ modelCatalog?: AgentModelCatalogService + /** The review writes a launch prompt's review reply asks for; absent, none runs. */ + reviewRuntime?: StructuredAgentSessionReviewRuntime } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 963604477751..9915a3eebdbe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -67,7 +67,10 @@ export class StructuredAgentSessionHost { this.conversationDelivery.afterCommit(sessionId, journal) }, logger: sessionLogger.deferredStructuredAgentSessionLogger(() => this.deps.logger), - onOpened: (sessionId) => this.queued.drain.schedule(sessionId), + onOpened: (sessionId) => { + this.queued.drain.schedule(sessionId) + this.clientDelivery.observeReviewReplies(sessionId) + }, now: () => this.now() }) private readonly queued = wireStructuredAgentSessionQueuedMessages(this.sessions, () => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index a81ea5270c88..40f852a9329e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -5,6 +5,7 @@ // operation happened, so the durable answer usually comes back out of the // journal. Send is fail-closed: admission alone cannot prove non-delivery. +import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' @@ -64,6 +65,7 @@ export function sendPlan(params: { body: AgentJournalMessageItem retryUnknown?: true delivery?: 'queue-if-active' + reviewReply?: AgentSessionReviewReply userSend?: true beforeRun?: () => void }): MutationPlan { @@ -75,9 +77,13 @@ export function sendPlan(params: { operationIdScope: 'global', conversationWrite: true, markUnknownBeforeRun: true, - // `delivery` joins the OPERATION fingerprint only; the submission row keeps + // `delivery` and `reviewReply` join the OPERATION fingerprint only; the submission row keeps // the body-only fingerprint the reducer's echo-aliasing recomputes. - fields: { body: params.body, ...(params.delivery ? { delivery: params.delivery } : {}) }, + fields: { + body: params.body, + ...(params.delivery ? { delivery: params.delivery } : {}), + ...(params.reviewReply ? { reviewReply: params.reviewReply } : {}) + }, recoverUnknownFromDurableState: true, // `retryUnknown` is a compatibility-only client signal. A recorded send // always replays and never reaches the provider twice. @@ -88,7 +94,8 @@ export function sendPlan(params: { origin: params.userSend ? 'client' : 'host', clientMessageId, payloadFingerprint: sendBodyFingerprint(params.envelope.sessionId, params.body), - body: params.body + body: params.body, + ...(params.reviewReply ? { reviewReply: params.reviewReply } : {}) }) }, replay: (ctx, outcome) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index 10799cd451f4..65f0eea24cfb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -194,6 +194,7 @@ export async function maybeQueueStructuredAgentSessionSend( envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' + reviewReply?: unknown } ): Promise< | { ok: true; value: AgentSessionSendResult } @@ -201,7 +202,12 @@ export async function maybeQueueStructuredAgentSessionSend( | null > { const clientMessageId = params.envelope.clientOperationId - if (params.delivery !== 'queue-if-active' || !queuedMessageBodyIsTextOnly(params.body)) { + // A draft carries no review reply, so a send with one is recorded as a message now. + if ( + params.delivery !== 'queue-if-active' || + params.reviewReply !== undefined || + !queuedMessageBodyIsTextOnly(params.body) + ) { return null } // Asked again with no ledger answer: a send this host queued answers as its replay would — diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts index d411efc42395..1c57b00b7312 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts @@ -15,6 +15,7 @@ export async function runQueueableStructuredAgentSessionSend( envelope: { clientOperationId: string } body: AgentJournalMessageItem delivery?: 'queue-if-active' + reviewReply?: unknown userSend?: true }, immediate: () => Promise> diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts new file mode 100644 index 000000000000..c38d0beee249 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts @@ -0,0 +1,486 @@ +// A launch prompt's review reply (resolve the threads, post Orca's "fixing" replies) runs once the +// agent takes the message: never before, never for a message no agent took, and once across a +// restart, derived from the chat itself. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' +import type { PRComment } from '../../../shared/github/comment-types' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' +import { + AgentSessionPreSpawnError, + type StructuredAgentSessionAdapter +} from './structured-agent-session-adapter' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestAttachParams, + hostTestMessage, + hostTestOperationId, + resetHostTestOperationIds +} from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { attachForTests } from './structured-agent-session-attach-test-support' +import { + REVIEW_REPLY_WINDOW_MS, + reviewReplyReceiptIdentity +} from './structured-agent-session-review-replies' +import type { StructuredAgentSessionReviewRuntime } from './structured-agent-session-review-reply-runner' + +const CALLER = { callerKey: 'client-1' } +const FIXING = 'Fixing. Will be in the next commit' +const GITHUB: AgentSessionReviewReply = { + provider: 'github', + repoId: 'repo-1', + prNumber: 42, + prRepo: { owner: 'acme', repo: 'app' }, + resolve: ['thread-resolvable'], + replies: [{ commentId: 7, threadId: 'thread-7', path: 'src/a.ts', line: 3 }], + replyBody: FIXING, + conversationReply: 'Fixing the review summary.' +} + +function eventually(assertion: () => void | Promise): Promise { + return vi.waitFor(assertion, { timeout: 10_000 }) +} + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let beforeSpawn = vi.fn<() => Promise>() +let dispatch = vi.fn() +let onPRComments: PRComment[] = [] +let review: { + [Method in keyof StructuredAgentSessionReviewRuntime]: ReturnType< + typeof vi.fn + > +} + +function reviewRuntime() { + return { + resolveRepoReviewThread: vi.fn( + async () => true + ), + resolveGitLabRepoMRDiscussion: vi.fn< + StructuredAgentSessionReviewRuntime['resolveGitLabRepoMRDiscussion'] + >(async () => ({ ok: true as const })), + addRepoPRReviewCommentReply: vi.fn< + StructuredAgentSessionReviewRuntime['addRepoPRReviewCommentReply'] + >(async () => ({ ok: true as const, comment: postedComment(FIXING) })), + addRepoIssueComment: vi.fn( + async () => ({ ok: true as const, comment: postedComment('summary') }) + ), + getRepoPRComments: vi.fn( + async () => onPRComments + ) + } +} + +function postedComment(body: string, extra: Partial = {}): PRComment { + return { + id: 900, + author: 'me', + authorAvatarUrl: '', + body, + createdAt: new Date().toISOString(), + url: 'https://github.com/acme/app/pull/42#discussion_r900', + ...extra + } +} + +function startHost(): void { + host = new StructuredAgentSessionHost({ + logger: createStructuredAgentSessionLogger(), + store, + adapter: { + acquire: vi.fn(async ({ fence, spawnToken }) => { + await beforeSpawn() + return { + process: { + hostId: 'local', + pid: 4242, + processStartTimeMs: 1_700_000_000_000, + spawnToken + }, + link: { + linkId: `link-${fence}`, + handle: { provider: 'codex' as const, threadId: THREAD }, + origin: store.getRecord(SESSION)?.providerHandleChain.length + ? ('resumed' as const) + : ('created' as const), + mintedAtFence: fence, + observedAt: NOW + }, + acquisitionGeneration: `generation-${fence}` + } + }), + releaseAcquisition: vi.fn(async () => true), + dispatch, + cancelTurn: vi.fn(async () => ({ cancelled: true })), + answerPrompt: vi.fn(async () => undefined), + setOption: vi.fn(async () => undefined) + }, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1', + mintSpawnToken: () => `spawn-${beforeSpawn.mock.calls.length + 1}`, + now: () => NOW, + setStartRetryTimer: () => () => {}, + reviewRuntime: review + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-review-replies-')) + resetHostTestOperationIds() + onPRComments = [] + review = reviewRuntime() + beforeSpawn = vi.fn(async () => undefined) + dispatch = vi.fn(async () => ({ state: 'admitted' as const })) + store = await openTestAgentSessionRecordStore(root) + startHost() + await expect( + attachForTests(host, CALLER, hostTestAttachParams(null, { providerHandle: undefined })) + ).resolves.toMatchObject({ ok: true }) + // At rest, so each message starts the agent, as a new chat's first one does. + await host.close(SESSION, 'evict') + beforeSpawn.mockClear() +}) + +afterEach(async () => { + await host.flushAllStreamedEvents() + await rm(root, { recursive: true, force: true }) +}) + +/** Lets whatever the last commits set off run, short of a quit. */ +async function settled(): Promise { + await host.flushStreamedEvents(SESSION) + await new Promise((resolve) => setTimeout(resolve, 20)) +} + +function fence(): number { + return store.getRecord(SESSION)?.lease.runtimeFence ?? 0 +} + +function sendParams(text: string, reviewReply: AgentSessionReviewReply, clientOperationId: string) { + const body = hostTestMessage(text) + return { + envelope: { + sessionId: SESSION, + clientOperationId, + expectedRuntimeFence: fence(), + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields: { body, reviewReply } + }) + }, + body, + reviewReply, + userSend: true as const + } +} + +async function launchPrompt(reviewReply: AgentSessionReviewReply = GITHUB): Promise { + const sent = await host.send( + CALLER, + sendParams('resolve these', reviewReply, hostTestOperationId()) + ) + expect(sent).toMatchObject({ ok: true }) + return sent.ok ? sent.value.clientMessageId : '' +} + +async function submission(clientMessageId: string): Promise { + return (await host.journalSnapshot(SESSION)).submissions.find( + (entry) => entry.clientMessageId === clientMessageId + ) +} + +async function handedOver(clientMessageId: string): Promise { + await eventually(async () => + expect(dispatch.mock.calls.map(([input]) => input.clientMessageId)).toContain(clientMessageId) + ) +} + +function accept(clientMessageId: string) { + return host.settleLateDispatch({ + sessionId: SESSION, + clientMessageId, + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 0 } + }) +} + +function writes(): number { + return ( + review.resolveRepoReviewThread.mock.calls.length + + review.resolveGitLabRepoMRDiscussion.mock.calls.length + + review.addRepoPRReviewCommentReply.mock.calls.length + + review.addRepoIssueComment.mock.calls.length + ) +} + +/** Whether the chat holds the message's receipt, and the line it shows if any. */ +async function receipt(clientMessageId: string) { + const itemId = agentJournalItemKey(reviewReplyReceiptIdentity(clientMessageId)) + const journal = host.collaboratorsForTests().sessions.get(SESSION)?.journal + const line = (await host.journalSnapshot(SESSION)).items.find((item) => item.itemId === itemId) + return { written: journal?.itemWritten(itemId) ?? false, line: line?.body } +} + +async function settledReceipt(clientMessageId: string) { + await eventually(async () => expect((await receipt(clientMessageId)).written).toBe(true)) + return receipt(clientMessageId) +} + +/** The next start waits for the test. */ +function heldStart(): { release: () => void } { + const held = Promise.withResolvers() + beforeSpawn.mockImplementationOnce(() => held.promise) + return { release: () => held.resolve() } +} + +function notInstalled(): AgentSessionPreSpawnError { + return new AgentSessionPreSpawnError(new Error('codex not installed'), { + reason: 'providerMissing', + needsUser: true + }) +} + +function retry(clientMessageId: string) { + return host.retryMessage(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: fence(), + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.retryMessage', + sessionId: SESSION, + fields: { clientMessageId } + }) + }, + clientMessageId + }) +} + +describe('a launch prompt with a review reply', () => { + it('writes nothing while the agent has not taken it, then everything once when it does', async () => { + const id = await launchPrompt() + await handedOver(id) + await settled() + expect(writes()).toBe(0) + + await accept(id) + + await settledReceipt(id) + expect(review.resolveRepoReviewThread).toHaveBeenCalledExactlyOnceWith( + 'id:repo-1', + 'thread-resolvable', + true, + GITHUB.prRepo + ) + expect(review.addRepoPRReviewCommentReply).toHaveBeenCalledExactlyOnceWith('id:repo-1', { + prNumber: 42, + commentId: 7, + body: FIXING, + threadId: 'thread-7', + path: 'src/a.ts', + line: 3, + prRepo: GITHUB.prRepo + }) + expect(review.addRepoIssueComment).toHaveBeenCalledExactlyOnceWith( + 'id:repo-1', + 42, + 'Fixing the review summary.', + GITHUB.prRepo + ) + // A live accept needs no read of the PR, and all going through shows nothing in the chat. + expect(review.getRepoPRComments).not.toHaveBeenCalled() + expect((await receipt(id)).line).toBeUndefined() + + // Later commits re-derive nothing. + await launchPrompt({ provider: 'gitlab', repoId: 'repo-1', iid: 1, resolve: [] }) + await settled() + expect(writes()).toBe(3) + }) + + it('writes nothing for a message whose start failed for good, and runs once its Retry is taken', async () => { + beforeSpawn.mockRejectedValueOnce(notInstalled()) + const id = await launchPrompt() + await eventually(async () => expect((await submission(id))?.dispatchState).toBe('rejected')) + await settled() + expect(writes()).toBe(0) + + await expect(retry(id)).resolves.toMatchObject({ ok: true }) + await handedOver(id) + expect(writes()).toBe(0) + await accept(id) + + await settledReceipt(id) + expect(writes()).toBe(3) + }) + + it('writes nothing for a message a Stop withdrew, or one the chat closed on', async () => { + const start = heldStart() + const stopped = await launchPrompt() + const stop = host.cancel(CALLER, { + envelope: { + sessionId: SESSION, + clientOperationId: hostTestOperationId(), + expectedRuntimeFence: fence(), + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: SESSION, + fields: {} + }) + } + }) + start.release() + await stop + await eventually(async () => + expect((await submission(stopped))?.dispatchState).toBe('rejected') + ) + + await host.close(SESSION, 'evict') + const closing = heldStart() + const closed = await launchPrompt() + const close = host.close(SESSION, 'user-close') + closing.release() + await close + await host.journalSnapshot(SESSION) + await eventually(async () => expect((await submission(closed))?.dispatchState).toBe('rejected')) + await settled() + expect(writes()).toBe(0) + }) + + it('says why on its own line, once, when a write fails, and a reopen changes nothing', async () => { + review.addRepoPRReviewCommentReply.mockResolvedValueOnce({ ok: false, error: 'HTTP 401' }) + const id = await launchPrompt() + await handedOver(id) + await accept(id) + + const { line } = await settledReceipt(id) + expect(line).toMatchObject({ + kind: 'status', + text: "Orca couldn't mark the review comments sent with this message as being fixed. Resolve or reply to them yourself.", + failure: { kind: 'reviewReplyFailed', detail: { text: 'HTTP 401', audience: 'log' } } + }) + // The rest still went through. + expect(writes()).toBe(3) + + await host.flushAllStreamedEvents() + startHost() + await host.journalSnapshot(SESSION) + await settled() + expect(writes()).toBe(3) + }) + + it('resolves GitLab discussions, and posts nothing', async () => { + const id = await launchPrompt({ + provider: 'gitlab', + repoId: 'repo-2', + iid: 8, + resolve: ['discussion-1', 'discussion-2'] + }) + await handedOver(id) + await accept(id) + + await settledReceipt(id) + expect(review.resolveGitLabRepoMRDiscussion.mock.calls).toEqual([ + ['id:repo-2', 8, 'discussion-1', true], + ['id:repo-2', 8, 'discussion-2', true] + ]) + expect(writes()).toBe(2) + }) +}) + +describe('a review reply cut off by a restart', () => { + /** Accepted, with Orca dying while the writes run: no receipt reaches the chat. */ + async function acceptedThenCrashed(): Promise { + review.addRepoIssueComment.mockImplementationOnce(() => new Promise(() => {})) + const id = await launchPrompt() + await handedOver(id) + await accept(id) + await eventually(() => expect(review.addRepoIssueComment).toHaveBeenCalled()) + await host.flushAllStreamedEvents() + startHost() + review.resolveRepoReviewThread.mockClear() + review.addRepoPRReviewCommentReply.mockClear() + review.addRepoIssueComment.mockClear() + return id + } + + it('posts only what the PR does not show yet, once the chat opens again', async () => { + const id = await acceptedThenCrashed() + onPRComments = [postedComment(FIXING, { threadId: 'thread-7', path: 'src/a.ts' })] + + await host.journalSnapshot(SESSION) + + await settledReceipt(id) + expect(review.getRepoPRComments).toHaveBeenCalledOnce() + expect(review.addRepoPRReviewCommentReply).not.toHaveBeenCalled() + expect(review.addRepoIssueComment).toHaveBeenCalledOnce() + expect(review.resolveRepoReviewThread).toHaveBeenCalledOnce() + }) + + it('posts nothing once the window since the agent took it has passed', async () => { + await acceptedThenCrashed() + // The journal stamps its rows, and reads the window, on the wall clock. + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(Date.now() + REVIEW_REPLY_WINDOW_MS + 1) + try { + await host.journalSnapshot(SESSION) + await settled() + + expect(writes()).toBe(0) + } finally { + vi.useRealTimers() + } + }) +}) + +describe('what a send may carry', () => { + it('is recorded as a message, never held as a draft, while the agent works', async () => { + const working = await launchPrompt({ + provider: 'gitlab', + repoId: 'repo-1', + iid: 1, + resolve: [] + }) + await handedOver(working) + const params = sendParams('resolve these too', GITHUB, hostTestOperationId()) + const fields = { body: params.body, delivery: 'queue-if-active', reviewReply: GITHUB } + const sent = await host.send(CALLER, { + ...params, + envelope: { + ...params.envelope, + payloadFingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: SESSION, + fields + }) + }, + delivery: 'queue-if-active' + }) + + expect(sent).toMatchObject({ ok: true, value: { submission: { dispatchState: 'pending' } } }) + }) + + it('is part of the send: the same id with another review reply is refused', async () => { + const operation = hostTestOperationId() + await expect(host.send(CALLER, sendParams('x', GITHUB, operation))).resolves.toMatchObject({ + ok: true + }) + const other = { ...GITHUB, resolve: ['another-thread'] } + await expect(host.send(CALLER, sendParams('x', other, operation))).resolves.toMatchObject({ + ok: false + }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts new file mode 100644 index 000000000000..bfe44ef87a97 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts @@ -0,0 +1,156 @@ +// A launch prompt's review reply runs once the agent takes the message. What is owed is derived, +// never stored: a message carrying a review reply, accepted within the window, whose chat has no +// receipt for it. The receipt is an item in the chat under the message's own id: a status line when +// a write failed, a tombstone (nothing shown) when all went through. + +import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' +import { agentSessionFailureFact, providerDiagnostic } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' +import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' +import { runStructuredAgentSessionReviewReply } from './structured-agent-session-review-reply-runner' + +/** A message accepted longer ago than this is never re-derived: a crash's leftover must not post + * days later on some reopen. Measured from acceptance, so a late Retry still posts. */ +export const REVIEW_REPLY_WINDOW_MS = 24 * 60 * 60 * 1000 + +export function reviewReplyReceiptIdentity(clientMessageId: string): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: `${clientMessageId}#review-reply` } +} + +/** The receipt: the failure said once on its own line, or a tombstone that shows nothing. */ +export async function writeStructuredAgentSessionReviewReplyReceipt( + journal: AgentSessionJournal, + fence: number, + clientMessageId: string, + failure: string | null +): Promise { + const identity = reviewReplyReceiptIdentity(clientMessageId) + if (failure === null) { + await journal.appendTombstone(identity, { fence }) + return + } + const detail = providerDiagnostic(failure, 'log') + await journal.appendItem( + identity, + { + kind: 'status', + ...agentSessionFailureWords( + agentSessionFailureFact('reviewReplyFailed', detail ? { detail } : {}), + { surface: 'row' } + ) + }, + { fence, turnScope: journal.liveTurnScope() } + ) +} + +export type StructuredAgentSessionReviewRepliesDeps = { + /** The writes; answers the first failure's words, or null when all went through. */ + run: ( + spec: AgentSessionReviewReply, + options: { acceptedAt: number; reread: boolean } + ) => Promise + /** Records the receipt in the chat; null `failure` hides it. */ + writeReceipt: ( + sessionId: string, + clientMessageId: string, + failure: string | null + ) => Promise + log: (message: string, error: unknown) => void +} + +export class StructuredAgentSessionReviewReplies { + /** Runs in flight or done in this process: a cache over the derived rule, never its truth. */ + private readonly claimed = new Set() + /** Messages seen waiting for the agent in this process: their run follows a live accept. */ + private readonly seenWaiting = new Set() + + constructor(private readonly deps: StructuredAgentSessionReviewRepliesDeps) {} + + /** Called on every journal publish and once as a journal opens. */ + observe(sessionId: string, journal: AgentSessionJournal): void { + for (const [clientMessageId, spec] of journal.reviewReplies()) { + const key = `${sessionId}:${clientMessageId}` + const submission = journal.submission(clientMessageId) + if (!submission || this.claimed.has(key)) { + continue + } + if (submission.dispatchState !== 'accepted') { + if (submission.dispatchState === 'pending' || submission.dispatchState === 'unknown') { + this.seenWaiting.add(key) + } + continue + } + const acceptedAt = submission.resolvedAt + if ( + acceptedAt === null || + journal.clock() - acceptedAt > REVIEW_REPLY_WINDOW_MS || + journal.itemWritten(agentJournalItemKey(reviewReplyReceiptIdentity(clientMessageId))) + ) { + continue + } + this.claimed.add(key) + // Accepted before this process saw it wait: an earlier run may have posted before its receipt. + const reread = !this.seenWaiting.delete(key) + void this.settle(sessionId, clientMessageId, spec, { acceptedAt, reread }) + } + } + + private async settle( + sessionId: string, + clientMessageId: string, + spec: AgentSessionReviewReply, + options: { acceptedAt: number; reread: boolean } + ): Promise { + let failure: string | null + try { + failure = await this.deps.run(spec, options) + } catch (error) { + this.deps.log('review reply: the run failed', error) + failure = error instanceof Error ? error.message : String(error) + } + // A receipt the chat refuses (closed, read-only) is re-derived on its next open in the window. + await this.deps + .writeReceipt(sessionId, clientMessageId, failure) + .catch((error: unknown) => this.deps.log('review reply: the receipt was not written', error)) + } +} + +/** The host's review replies: writes through its runtime, receipts at the chat's current fence. */ +export function createStructuredAgentSessionHostReviewReplies(host: { + deps: () => Pick + journal: (sessionId: string) => AgentSessionJournal | undefined +}): { observe: (sessionId: string) => void } { + const log = (message: string, error: unknown): void => + host.deps().logger.warn(message, { scope: 'review-reply', error }) + const replies = new StructuredAgentSessionReviewReplies({ + run: async (spec, options) => { + const runtime = host.deps().reviewRuntime + if (!runtime) { + throw new Error('This host cannot write to the review.') + } + return runStructuredAgentSessionReviewReply(runtime, spec, { ...options, log }) + }, + writeReceipt: async (sessionId, clientMessageId, failure) => { + const journal = host.journal(sessionId) + if (!journal) { + throw new Error(`chat ${sessionId} is closed`) + } + const fence = structuredAgentSessionConversationFence(host.deps().store, sessionId) + await writeStructuredAgentSessionReviewReplyReceipt(journal, fence, clientMessageId, failure) + }, + log + }) + return { + // A host with no review surface owes nothing, so it never writes a receipt either. + observe: (sessionId) => { + const journal = host.journal(sessionId) + if (journal && host.deps().reviewRuntime) { + replies.observe(sessionId, journal) + } + } + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-review-reply-runner.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-review-reply-runner.ts new file mode 100644 index 000000000000..6b0389121b3a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-review-reply-runner.ts @@ -0,0 +1,156 @@ +// The review writes a message's review reply asks for, through the runtime's repo-scoped GitHub and +// GitLab methods: the ones the review RPCs call. Resolves are idempotent; a reply is not, so a run +// that may repeat one already posted (`reread`) first reads the PR and skips what is there. + +import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' +import type { PRComment } from '../../../shared/github/comment-types' +import type { RuntimeReviewCommandSurface } from '../../runtime/runtime-review-command-surface' + +export type StructuredAgentSessionReviewRuntime = Pick< + RuntimeReviewCommandSurface, + | 'resolveRepoReviewThread' + | 'resolveGitLabRepoMRDiscussion' + | 'addRepoPRReviewCommentReply' + | 'addRepoIssueComment' + | 'getRepoPRComments' +> + +/** The checks panel's ceiling: the shared GitHub client keeps four calls in flight. */ +const REVIEW_REPLY_CONCURRENCY = 4 + +type ReviewWrite = () => Promise + +/** Runs every write; answers the first failure's words, or null when all went through. */ +export async function runStructuredAgentSessionReviewReply( + runtime: StructuredAgentSessionReviewRuntime, + spec: AgentSessionReviewReply, + options: { + /** When the agent took the message: a reply posted since is this message's. */ + acceptedAt: number + /** A run that may follow an earlier one cut off before its receipt. */ + reread: boolean + log: (message: string, error?: unknown) => void + } +): Promise { + const repo = `id:${spec.repoId}` + const writes: ReviewWrite[] = + spec.provider === 'gitlab' + ? spec.resolve.map((discussionId) => async () => { + const resolved = await runtime.resolveGitLabRepoMRDiscussion( + repo, + spec.iid, + discussionId, + true + ) + return resolved.ok ? null : resolved.error + }) + : await gitHubWrites(runtime, repo, spec, options) + const failures = await runBounded(writes, options.log) + return failures[0] ?? null +} + +async function gitHubWrites( + runtime: StructuredAgentSessionReviewRuntime, + repo: string, + spec: Extract, + options: { acceptedAt: number; reread: boolean; log: (message: string, error?: unknown) => void } +): Promise { + const posted = options.reread ? await postedSince(runtime, repo, spec, options) : [] + const alreadyPosted = (body: string, at: { threadId?: string; path?: string } | null) => + posted.some( + (comment) => + comment.body.trim() === body.trim() && + (at === null + ? !comment.threadId && !comment.path + : at.threadId + ? comment.threadId === at.threadId + : comment.path === at.path) + ) + const prRepo = spec.prRepo ?? null + const conversationReply = spec.conversationReply + return [ + ...spec.resolve.map( + (threadId) => async () => + (await runtime.resolveRepoReviewThread(repo, threadId, true, prRepo)) + ? null + : 'Could not resolve the review thread.' + ), + ...spec.replies + .filter((reply) => !alreadyPosted(spec.replyBody, reply)) + .map((reply) => async () => { + const result = await runtime.addRepoPRReviewCommentReply(repo, { + prNumber: spec.prNumber, + commentId: reply.commentId, + body: spec.replyBody, + ...(reply.threadId ? { threadId: reply.threadId } : {}), + ...(reply.path ? { path: reply.path } : {}), + ...(reply.line !== undefined ? { line: reply.line } : {}), + prRepo + }) + return result.ok ? null : result.error + }), + ...(conversationReply && !alreadyPosted(conversationReply, null) + ? [ + async () => { + const result = await runtime.addRepoIssueComment( + repo, + spec.prNumber, + conversationReply, + prRepo + ) + return result.ok ? null : result.error + } + ] + : []) + ] +} + +/** The PR's comments written since the agent took the message. A failed read answers none, which + * can post one reply twice: reported, never blocking the rest. */ +async function postedSince( + runtime: StructuredAgentSessionReviewRuntime, + repo: string, + spec: Extract, + options: { acceptedAt: number; log: (message: string, error?: unknown) => void } +): Promise { + if (spec.replies.length === 0 && !spec.conversationReply) { + return [] + } + try { + const comments = await runtime.getRepoPRComments(repo, spec.prNumber, spec.prRepo ?? null, { + noCache: true + }) + // GitHub stamps whole seconds. + const since = Math.floor(options.acceptedAt / 1000) * 1000 + return comments.filter((comment) => Date.parse(comment.createdAt) >= since) + } catch (error) { + options.log('review reply: could not read the PR before replying; a reply may repeat', error) + return [] + } +} + +async function runBounded( + writes: readonly ReviewWrite[], + log: (message: string, error?: unknown) => void +): Promise { + const failures: string[] = [] + let next = 0 + const worker = async (): Promise => { + while (next < writes.length) { + const write = writes[next++]! + try { + const failure = await write() + if (failure !== null) { + failures.push(failure) + } + } catch (error) { + log('review reply: a review write failed', error) + failures.push(error instanceof Error ? error.message : String(error)) + } + } + } + await Promise.all( + Array.from({ length: Math.min(REVIEW_REPLY_CONCURRENCY, writes.length) }, worker) + ) + return failures +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 8c50d95126cf..5a1d8995c3a5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -6,6 +6,7 @@ // row the next attach settles as `unknown`, whereas the reverse would lose a // turn the provider already accepted. +import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords, @@ -122,6 +123,7 @@ export async function performSend( body: AgentJournalMessageItem /** Who asked for the turn; absent on callers that predate it. */ origin?: 'client' | 'host' + reviewReply?: AgentSessionReviewReply } ): Promise> { const existing = ctx.journal diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 4333cc7e3a5e..4f4b1a25f237 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -181,7 +181,18 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis // Read per sweep tick from the orchestration database: a worker whose dispatch is open keeps // its agent running. No database answers no. hasOpenDispatch: (record) => - structuredWorkerOwesWork(this.getOrchestrationDbIfAvailable?.() ?? null, record) + structuredWorkerOwesWork(this.getOrchestrationDbIfAvailable?.() ?? null, record), + // A launch prompt's review reply writes through the same methods the review RPCs call. + reviewRuntime: { + resolveRepoReviewThread: (...args) => + this.gitHubReviewMutations.resolveRepoReviewThread(...args), + resolveGitLabRepoMRDiscussion: (...args) => + this.gitLabMutationCommands.resolveGitLabRepoMRDiscussion(...args), + addRepoPRReviewCommentReply: (...args) => + this.gitHubIssueComments.addRepoPRReviewCommentReply(...args), + addRepoIssueComment: (...args) => this.gitHubIssueComments.addRepoIssueComment(...args), + getRepoPRComments: (...args) => this.gitHubReviewQueries.getRepoPRComments(...args) + } }) } } diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 3785fdeaf5ce..909826d0f557 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -127,6 +127,7 @@ export type StructuredAgentSessionRuntimeDeps = { statusSink?: StructuredAgentSessionHostDeps['statusSink'] /** See `StructuredAgentSessionHostDeps.hasOpenDispatch`. */ hasOpenDispatch?: StructuredAgentSessionHostDeps['hasOpenDispatch'] + reviewRuntime?: StructuredAgentSessionHostDeps['reviewRuntime'] /** The account home a structured launch would pin right now, for catalog * reads with no session record. Absent disables the catalog surface. */ resolveAgentAccountHome?: RuntimeAgentAccountHomeResolver @@ -322,6 +323,7 @@ async function installOnJournal( ...(deps.onSessionStatusChanged ? { onSessionStatusChanged: deps.onSessionStatusChanged } : {}), ...(deps.statusSink ? { statusSink: deps.statusSink } : {}), ...(deps.hasOpenDispatch ? { hasOpenDispatch: deps.hasOpenDispatch } : {}), + ...(deps.reviewRuntime ? { reviewRuntime: deps.reviewRuntime } : {}), ...(await modelCatalogHostDeps({ store, deps, envResolvers })) }) setStructuredAgentSessionHost(host) diff --git a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts index 59b1fa10802b..eb4bafc630c2 100644 --- a/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-failure-words-text.ts @@ -254,7 +254,9 @@ const PIECES: Record + translate('components.native-chat.failureWords.reviewReplyFailed', COPY.reviewReplyFailed) } export const sayAgentSessionFailureTranslated: AgentSessionFailureSay = (id, values = {}) => diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index e8aac316801d..86a1280d2ca9 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2725,7 +2725,6 @@ }, "failureWords": { "accountSwitchInProgress": "A Claude account switch is in progress.", - "tryAgainAfterSwitch": "Try again after it finishes.", "answerUnconfirmed": "Your answer was recorded but the agent did not confirm it.", "attachmentAtMost": "{{agent}} accepts at most {{limit}} images in one message, so this message was not sent.", "attachmentEmpty": "An image on this message is empty, so the message was not sent.", @@ -2778,6 +2777,7 @@ "providerRetryingQuoted": "{{agent}} is retrying: {{detail}}.", "providerStartFailed": "{{agent}} stopped before it finished starting.", "queueFull": "Too many messages were waiting for the agent, so this one was not sent.", + "reviewReplyFailed": "Orca couldn't mark the review comments sent with this message as being fixed. Resolve or reply to them yourself.", "runCommandAgain": "Run /{{command}} again.", "sendToTryAgain": "Send your message to try again.", "signInFirst": "Sign in first.", @@ -2786,6 +2786,7 @@ "stopRefused": "{{agent}} didn't stop.", "stopRefusedQuoted": "{{agent}} didn't stop: {{detail}}.", "theAgent": "The agent", + "tryAgainAfterSwitch": "Try again after it finishes.", "writeFailed": "Orca couldn't hand this message to the agent, so it was not sent." }, "question": { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 0a3eb983be4c..82f4838ae8c4 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17573,7 +17573,8 @@ "providerRateLimited": "{{agent}} is rate-limited and retrying.", "providerRetrying": "{{agent}} hit a temporary problem and is retrying.", "providerRetryingQuoted": "{{agent}} is retrying: {{detail}}.", - "previousExitUnverifiable": "Orca couldn't confirm {{agent}}'s previous process ended. Messages wait to be sent until Orca confirms it has ended." + "previousExitUnverifiable": "Orca couldn't confirm {{agent}}'s previous process ended. Messages wait to be sent until Orca confirms it has ended.", + "reviewReplyFailed": "Orca couldn't mark the review comments sent with this message as being fixed. Resolve or reply to them yourself." }, "writeNotice": { "notDoneReadHistory": "This chat's history couldn't be loaded.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index cb2f6d22497c..d826632d9145 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -15005,7 +15005,8 @@ "providerRateLimited": "{{agent}} alcanzó un límite de solicitudes y está reintentando.", "providerRetrying": "{{agent}} tuvo un problema temporal y está reintentando.", "providerRetryingQuoted": "{{agent}} está reintentando: {{detail}}.", - "previousExitUnverifiable": "Orca no pudo confirmar que el proceso anterior de {{agent}} terminó. Los mensajes esperan para enviarse hasta que Orca confirme que terminó." + "previousExitUnverifiable": "Orca no pudo confirmar que el proceso anterior de {{agent}} terminó. Los mensajes esperan para enviarse hasta que Orca confirme que terminó.", + "reviewReplyFailed": "Orca no pudo marcar como en corrección los comentarios de revisión enviados con este mensaje. Resuélvelos o respóndelos tú." }, "writeNotice": { "notDoneReadHistory": "No se pudo cargar el historial de este chat.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index ba96cb29d824..d8c413dd0249 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17471,7 +17471,8 @@ "providerRateLimited": "{{agent}} est limité en débit et réessaie.", "providerRetrying": "{{agent}} a rencontré un problème temporaire et réessaie.", "providerRetryingQuoted": "{{agent}} réessaie : {{detail}}.", - "previousExitUnverifiable": "Orca n'a pas pu confirmer que le processus précédent de {{agent}} s'est arrêté. Les messages attendent d'être envoyés jusqu'à ce qu'Orca confirme son arrêt." + "previousExitUnverifiable": "Orca n'a pas pu confirmer que le processus précédent de {{agent}} s'est arrêté. Les messages attendent d'être envoyés jusqu'à ce qu'Orca confirme son arrêt.", + "reviewReplyFailed": "Orca n'a pas pu marquer comme en cours de correction les commentaires de revue envoyés avec ce message. Résolvez-les ou répondez-y vous-même." }, "writeNotice": { "notDoneReadHistory": "L'historique de ce chat n'a pas pu être chargé.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 097c149d6cc0..8f91e98f8af4 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17407,7 +17407,8 @@ "providerRateLimited": "{{agent}} はレート制限を受けているため、再試行しています。", "providerRetrying": "{{agent}} で一時的な問題が発生したため、再試行しています。", "providerRetryingQuoted": "{{agent}} は再試行しています: {{detail}}。", - "previousExitUnverifiable": "Orca は {{agent}} の前のプロセスが終了したことを確認できませんでした。Orca が終了を確認するまで、メッセージは送信を待機します。" + "previousExitUnverifiable": "Orca は {{agent}} の前のプロセスが終了したことを確認できませんでした。Orca が終了を確認するまで、メッセージは送信を待機します。", + "reviewReplyFailed": "このメッセージと一緒に送ったレビューコメントを、Orca は修正中としてマークできませんでした。ご自身で解決するか返信してください。" }, "writeNotice": { "notDoneReadHistory": "このチャットの履歴を読み込めませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d4ba8c464d75..f3a4ecf075c7 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17407,7 +17407,8 @@ "providerRateLimited": "{{agent}}이(가) 속도 제한에 걸려 다시 시도하고 있습니다.", "providerRetrying": "{{agent}}에 일시적인 문제가 발생하여 다시 시도하고 있습니다.", "providerRetryingQuoted": "{{agent}}이(가) 다시 시도하고 있습니다: {{detail}}.", - "previousExitUnverifiable": "Orca가 {{agent}}의 이전 프로세스가 종료되었는지 확인하지 못했습니다. Orca가 종료를 확인할 때까지 메시지는 전송을 기다립니다." + "previousExitUnverifiable": "Orca가 {{agent}}의 이전 프로세스가 종료되었는지 확인하지 못했습니다. Orca가 종료를 확인할 때까지 메시지는 전송을 기다립니다.", + "reviewReplyFailed": "Orca가 이 메시지와 함께 보낸 리뷰 댓글을 수정 중으로 표시하지 못했습니다. 직접 해결하거나 답글을 남기세요." }, "writeNotice": { "notDoneReadHistory": "이 채팅의 기록을 불러오지 못했습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 589b584d523d..717ccebff2c6 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17372,7 +17372,8 @@ "providerRateLimited": "{{agent}} 已被限流,正在重试。", "providerRetrying": "{{agent}} 遇到临时问题,正在重试。", "providerRetryingQuoted": "{{agent}} 正在重试:{{detail}}。", - "previousExitUnverifiable": "Orca 无法确认 {{agent}} 的上一个进程已结束。在 Orca 确认其已结束之前,消息会等待发送。" + "previousExitUnverifiable": "Orca 无法确认 {{agent}} 的上一个进程已结束。在 Orca 确认其已结束之前,消息会等待发送。", + "reviewReplyFailed": "Orca 无法将随此消息发送的评审评论标记为正在修复。请自行解决或回复它们。" }, "writeNotice": { "notDoneReadHistory": "无法加载此聊天的历史记录。", diff --git a/src/shared/agent-session-failure-copy.ts b/src/shared/agent-session-failure-copy.ts index 88a10414db6b..ad14285a834d 100644 --- a/src/shared/agent-session-failure-copy.ts +++ b/src/shared/agent-session-failure-copy.ts @@ -87,7 +87,9 @@ export const AGENT_SESSION_FAILURE_COPY = { providerRetrying: '{{agent}} hit a temporary problem and is retrying.', providerRetryingQuoted: '{{agent}} is retrying: {{detail}}.', previousExitUnverifiable: - "Orca couldn't confirm {{agent}}'s previous process ended. Messages wait to be sent until Orca confirms it has ended." + "Orca couldn't confirm {{agent}}'s previous process ended. Messages wait to be sent until Orca confirms it has ended.", + reviewReplyFailed: + "Orca couldn't mark the review comments sent with this message as being fixed. Resolve or reply to them yourself." } as const export type AgentSessionFailureCopyId = keyof typeof AGENT_SESSION_FAILURE_COPY diff --git a/src/shared/agent-session-failure-words.ts b/src/shared/agent-session-failure-words.ts index 0bef9eacd96c..7b755660ad3e 100644 --- a/src/shared/agent-session-failure-words.ts +++ b/src/shared/agent-session-failure-words.ts @@ -286,7 +286,8 @@ const FAILURE_SENTENCES = { retry?.cause ), previousExitUnverifiable: (context, _fact, _surface, say) => - say('previousExitUnverifiable', agent(say, context)) + say('previousExitUnverifiable', agent(say, context)), + reviewReplyFailed: (_context, _fact, _surface, say) => say('reviewReplyFailed') } satisfies Record /** The sentence a person reads for this fact on this surface; never a marker. */ diff --git a/src/shared/agent-session-failure.ts b/src/shared/agent-session-failure.ts index b04f0d883af6..a0451d9c4b65 100644 --- a/src/shared/agent-session-failure.ts +++ b/src/shared/agent-session-failure.ts @@ -51,7 +51,9 @@ export const AGENT_SESSION_FAILURE_KINDS = [ /** The provider is retrying a request its API refused; not a failure yet. */ 'providerRetrying', /** A message waits on a child a Stop could not prove gone: its exit is unverifiable. */ - 'previousExitUnverifiable' + 'previousExitUnverifiable', + /** The review replies and resolves a message carried did not all go through. */ + 'reviewReplyFailed' ] as const export type AgentSessionFailureKind = (typeof AGENT_SESSION_FAILURE_KINDS)[number] @@ -67,7 +69,8 @@ const STATUS_ROW_ONLY_FAILURE_KINDS = [ 'stopRefused', 'answerUnconfirmed', 'providerRetrying', - 'previousExitUnverifiable' + 'previousExitUnverifiable', + 'reviewReplyFailed' ] as const satisfies readonly AgentSessionFailureKind[] /** Why a message was not sent. A new failure kind is one of these until listed above. */ diff --git a/src/shared/agent-session-review-reply.ts b/src/shared/agent-session-review-reply.ts new file mode 100644 index 000000000000..414a4659741b --- /dev/null +++ b/src/shared/agent-session-review-reply.ts @@ -0,0 +1,61 @@ +// What "Resolve comments with AI" asks Orca to do on the review once the agent takes the launch +// prompt: resolve the threads the host can close, and post Orca's "fixing" reply on the ones it +// cannot (GitHub only). It rides the message, so whichever host records the message runs it. + +import { z } from 'zod' + +/** Bounds a spec so a message cannot carry an unbounded side effect. */ +export const MAX_REVIEW_REPLY_TARGETS = 500 +export const MAX_REVIEW_REPLY_BYTES = 64 * 1024 + +const Id = z.string().min(1).max(512) + +const GitHubThreadReply = z + .object({ + commentId: z.number().int().positive(), + threadId: Id.optional(), + path: z.string().min(1).max(4096).optional(), + line: z.number().int().positive().optional() + }) + .strict() + +const GitHubReviewReply = z + .object({ + provider: z.literal('github'), + repoId: Id, + prNumber: z.number().int().positive(), + prRepo: z.object({ owner: Id, repo: Id, host: Id.optional() }).strict().optional(), + /** Review threads to resolve. */ + resolve: z.array(Id).max(MAX_REVIEW_REPLY_TARGETS), + /** In-thread replies, each posting `replyBody` under its comment. */ + replies: z.array(GitHubThreadReply).max(MAX_REVIEW_REPLY_TARGETS), + replyBody: z.string().min(1).max(4096), + /** One PR conversation comment for every selected comment with no reply endpoint. */ + conversationReply: z.string().min(1).max(MAX_REVIEW_REPLY_BYTES).optional() + }) + .strict() + +const GitLabReviewReply = z + .object({ + provider: z.literal('gitlab'), + repoId: Id, + iid: z.number().int().positive(), + /** Merge request discussions to resolve. */ + resolve: z.array(Id).max(MAX_REVIEW_REPLY_TARGETS) + }) + .strict() + +export const AgentSessionReviewReplySchema = z + .discriminatedUnion('provider', [GitHubReviewReply, GitLabReviewReply]) + .refine( + (value) => new TextEncoder().encode(JSON.stringify(value)).byteLength <= MAX_REVIEW_REPLY_BYTES, + 'Review reply is too large' + ) + +export type AgentSessionReviewReply = z.infer + +/** A stored spec as a reader meets it; undefined for anything malformed, which then runs nothing. */ +export function readAgentSessionReviewReply(value: unknown): AgentSessionReviewReply | undefined { + const parsed = AgentSessionReviewReplySchema.safeParse(value) + return parsed.success ? parsed.data : undefined +} diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index fe8939ae03b7..fcde427c5491 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { AgentSessionReviewReplySchema } from '../agent-session-review-reply' import { isAgentSessionSurfaceTabId } from '../agent-session-surface-tab-id' import { isAgentSessionId } from '../agent-session-record' import { normalizeExecutionHostId } from '../execution-host' @@ -172,6 +173,10 @@ export const SendParams = z * older host refuses it: clients send it only when `agent-session.queued-messages.v1` is * advertised. Participates in the operation fingerprint, never the body fingerprint. */ delivery: z.literal('queue-if-active').optional(), + /** What Orca does on the review once the agent takes this message. Sent only by the desktop to + * its own host, with a launch prompt; strict, so an older host refuses it. Joins the + * operation fingerprint, never the body's. A send carrying one is never held as a draft. */ + reviewReply: AgentSessionReviewReplySchema.optional(), body: z .object({ kind: z.literal('message'), From cbe72221c0d0d1021905e113b8a3f1a4106fbaef Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:51:48 -0700 Subject: [PATCH 59/84] feat(checks-panel): Resolve comments with AI hands a structured chat its review writes On a structured chat the panel now plans the review reply at launch (threads to resolve, in-thread and conversation "fixing" replies, GitLab resolves) and the launch prompt carries it: on the outbox entry, on every send of it, and through the launch plan. The chat's host writes it once the agent takes the message, so the panel writes nothing, clears the selection at record, and shows no counts toast; a failed write shows once on the chat. A terminal agent has no message to carry it, so the panel still writes after the paste and keeps its toast. --- ...structured-agent-session-outbox-storage.ts | 15 +- .../SourceControlAgentActionDialog.tsx | 7 +- .../checks-panel/active-content-props.ts | 1 + .../checks-panel/active-content.tsx | 6 +- .../comment-resolution-review-reply.test.tsx | 180 ++++++++++++++++++ .../panel-content-rendering.test.tsx | 1 + .../use-checks-panel-ai-acknowledgement.tsx | 82 +++++--- .../pr-comment-review-reply-spec.test.ts | 86 +++++++++ .../pr-comment-review-reply-spec.ts | 93 +++++++++ .../pr-comments-ai-launch-ack.ts | 5 + .../runSourceControlAgentActionStart.ts | 17 +- .../useSourceControlAgentActionDialog.ts | 2 + .../useSourceControlAgentActionStart.ts | 7 +- .../src/lib/agent-session-launch-plan.ts | 10 +- .../src/lib/launch-agent-in-new-tab.ts | 7 +- ...structured-agent-session-launch-callers.ts | 15 ++ ...agent-session-launch-join-delivery.test.ts | 37 ++++ .../lib/structured-agent-session-launch.ts | 14 +- src/shared/agent-session-review-reply.ts | 8 + ...-agent-session-outbox-review-reply.test.ts | 70 +++++++ src/shared/structured-agent-session-outbox.ts | 12 +- 21 files changed, 618 insertions(+), 57 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx create mode 100644 src/renderer/src/components/right-sidebar/pr-comment-review-reply-spec.test.ts create mode 100644 src/renderer/src/components/right-sidebar/pr-comment-review-reply-spec.ts create mode 100644 src/shared/structured-agent-session-outbox-review-reply.test.ts diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts index ce270d60ddba..e6615e2d0f8b 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-storage.ts @@ -6,6 +6,7 @@ import { } from '../../../../shared/structured-agent-session-outbox' import { createStructuredAgentSessionOperationId } from '../../../../shared/structured-agent-session-mutation' import { createBrowserUuid } from '@/lib/browser-uuid' +import type { AgentSessionReviewReply } from '../../../../shared/agent-session-review-reply' const OUTBOX_PREFIX = 'orca:desktopStructuredAgentSessionOutbox:v1:' @@ -180,7 +181,7 @@ export function appendStructuredAgentSessionOutboxMessage( sessionId: string, text: string, attachments: readonly StructuredAgentSessionAttachment[] = [], - source?: 'launch' + launch?: Pick ): StructuredAgentSessionOutboxEntry | null { const entry = { ...createStructuredAgentSessionOutboxEntry({ @@ -190,7 +191,7 @@ export function appendStructuredAgentSessionOutboxMessage( attachments, queuedAt: Date.now() }), - ...(source ? { source } : {}) + ...(launch ? { source: 'launch' as const, ...launch } : {}) } return commitStructuredAgentSessionOutbox( sessionId, @@ -203,9 +204,15 @@ export function appendStructuredAgentSessionOutboxMessage( export function enqueueStructuredAgentSessionLaunchPrompt( sessionId: string, - text: string + text: string, + reviewReply?: AgentSessionReviewReply ): StructuredAgentSessionOutboxEntry | null { - return appendStructuredAgentSessionOutboxMessage(sessionId, text, [], 'launch') + return appendStructuredAgentSessionOutboxMessage( + sessionId, + text, + [], + reviewReply ? { reviewReply } : {} + ) } export function discardStructuredAgentSessionLaunchOutbox(sessionId: string): void { diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx index 1e508b917145..87dbea962f73 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx @@ -1,3 +1,4 @@ +import type { AgentSessionReviewReply } from '../../../../shared/agent-session-review-reply' import React from 'react' import { Dialog, @@ -46,7 +47,11 @@ export type SourceControlAgentActionDialogProps = { onLaunchAccepted?: () => void /** Fires when an accepted launch later failed to deliver its prompt. */ onLaunchAborted?: () => void - onLaunched?: () => void + /** `structuredChat`: the launch opened a structured chat, whose host writes `reviewReply` once + * the agent takes the message; the caller writes nothing to the review itself. */ + onLaunched?: (launch: { structuredChat: boolean }) => void + /** Planned at start, for a structured chat's launch prompt to carry. */ + reviewReply?: () => AgentSessionReviewReply | undefined startLabel?: string onStart?: (args: { agent: TuiAgent diff --git a/src/renderer/src/components/right-sidebar/checks-panel/active-content-props.ts b/src/renderer/src/components/right-sidebar/checks-panel/active-content-props.ts index fadef627d972..835416182f04 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/active-content-props.ts +++ b/src/renderer/src/components/right-sidebar/checks-panel/active-content-props.ts @@ -62,6 +62,7 @@ export type ChecksPanelActiveContentModel = Pick< Pick< ChecksPanelAiAcknowledgementState, | 'consumeClaimedCommentResolutionAfterDeliveryRef' + | 'buildLaunchReviewReply' | 'handleLaunchAborted' | 'handleLaunchAccepted' > & diff --git a/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx index 9f1cc498e312..b4643874d689 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx @@ -59,6 +59,7 @@ export function ChecksPanelActiveContent({ commentsSelectionClearRequest, conflictDetailsRefreshing, consumeClaimedCommentResolutionAfterDeliveryRef, + buildLaunchReviewReply, detachedHeadDisplay, editingTitle, getGitLabProjectRef, @@ -328,11 +329,12 @@ export function ChecksPanelActiveContent({ } onSaveAgentDefault={saveLaunchActionDefault} // Why: claims the ack payload when the tab exists; the host writes still wait for delivery. + reviewReply={buildLaunchReviewReply} onLaunchAccepted={handleLaunchAccepted} onLaunchAborted={handleLaunchAborted} - onLaunched={() => { + onLaunched={(launch) => { // Why: prompt delivery succeeded — the only point at which host replies/resolves may run. - consumeClaimedCommentResolutionAfterDeliveryRef.current() + consumeClaimedCommentResolutionAfterDeliveryRef.current(launch) if (agentComposerState?.actionId === 'resolveConflicts') { toast.success( translate( diff --git a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx new file mode 100644 index 000000000000..f2f180592704 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx @@ -0,0 +1,180 @@ +// @vitest-environment happy-dom + +// "Resolve comments with AI" into a structured chat hands its review writes to the launch prompt, +// whose host runs them once the agent takes the message: the panel itself writes nothing. A +// terminal agent has no message to carry them, so the panel still writes once the paste lands. + +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + launchAgentInNewTab: vi.fn(), + toastError: vi.fn(), + toastSuccess: vi.fn() +})) + +vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab: mocks.launchAgentInNewTab })) +vi.mock('@/lib/focus-terminal-tab-surface', () => ({ focusTerminalTabSurface: vi.fn() })) +vi.mock('sonner', () => ({ toast: { error: mocks.toastError, success: mocks.toastSuccess } })) + +import type { PRComment } from '../../../../../shared/github/comment-types' +import { + clearPendingPRCommentAiAck, + setPendingPRCommentAiAck, + type PendingPRCommentAiAck +} from '../pr-comments-ai-launch-ack' +import { runSourceControlAgentActionStart } from '../runSourceControlAgentActionStart' +import { useChecksPanelAiAcknowledgement } from './use-checks-panel-ai-acknowledgement' + +const REVIEW_KEY = 'repo-1::42::sha-1' + +function comment(overrides: Partial): PRComment { + return { + id: 1, + author: 'alice', + authorAvatarUrl: '', + body: 'Please update this.', + createdAt: '2026-05-14T00:00:00Z', + url: 'https://github.com/acme/widgets/pull/42#discussion_r1', + ...overrides + } +} + +/** An open review thread and a conversation comment. */ +function resolution(): PendingPRCommentAiAck { + const target = { repoPath: '/repos/widgets', repoId: 'repo-1', prNumber: 42 } + return { + reviewContextKey: REVIEW_KEY, + provider: 'github', + selectedGroups: [ + { + kind: 'thread', + threadId: 'T1', + root: comment({ id: 10, threadId: 'T1', path: 'src/a.ts', isResolved: false }), + replies: [] + }, + { + kind: 'standalone', + comment: comment({ id: 20, url: 'https://github.com/acme/widgets/pull/42#issuecomment-9' }) + } + ], + githubTarget: { ...target, prRepo: { owner: 'acme', repo: 'widgets' } }, + githubResolveTarget: target + } +} + +type AcknowledgementInput = Parameters[0] + +function acknowledgement() { + const pending = resolution() + setPendingPRCommentAiAck(pending) + const model = { + addPRConversationComment: vi.fn(async () => ({ + ok: true, + comment: comment({ id: 30 }) + })), + addPRReviewCommentReply: vi.fn(async () => ({ + ok: true, + comment: comment({ id: 31, threadId: 'T1' }) + })), + resolveReviewThread: vi.fn(async () => true), + asyncResultKeyRef: { current: REVIEW_KEY }, + claimedCommentResolutionRef: { current: null }, + commentsRef: { current: [] }, + commentsSelectionClearTokenRef: { current: 0 }, + pendingCommentResolutionRef: { current: pending }, + commentResolutionLaunchAcceptedRef: { current: false }, + setCommentResolutionAckBusyNow: vi.fn(), + setComments: vi.fn(), + setCommentsSelectionClearRequest: vi.fn(), + settings: null, + fetchComments: vi.fn(async () => {}), + fetchGitLabDetails: vi.fn(async () => {}) + } satisfies AcknowledgementInput + const hook = renderHook(() => useChecksPanelAiAcknowledgement(model)) + return { model, hook } +} + +function resolveCommentsWithAi(hook: ReturnType['hook']) { + return act(() => + runSourceControlAgentActionStart({ + selectedAgent: 'claude', + trimmedCommandInput: 'Fix the comments', + agentArgs: '', + agentArgsApply: false, + commandTemplate: '{basePrompt}', + saveTargetValue: 'none', + actionId: 'resolveComments', + repoId: null, + settings: null, + repo: null, + worktreeId: 'wt-1', + groupId: 'wt-1', + promptDelivery: 'submit-after-ready', + launchPlatform: 'linux', + launchSource: 'task_page', + reviewReply: hook.result.current.buildLaunchReviewReply, + onLaunchAccepted: hook.result.current.handleLaunchAccepted, + onLaunchAborted: hook.result.current.handleLaunchAborted, + onLaunched: (launch) => + hook.result.current.consumeClaimedCommentResolutionAfterDeliveryRef.current(launch), + onClose: vi.fn() + }) + ) +} + +const DELIVERED = Promise.resolve({ delivered: true, failureNotified: false }) + +describe('Resolve comments with AI', () => { + beforeEach(() => { + vi.clearAllMocks() + clearPendingPRCommentAiAck() + }) + afterEach(() => { + cleanup() + }) + + it('hands a structured chat its review writes and writes nothing itself', async () => { + const { model, hook } = acknowledgement() + mocks.launchAgentInNewTab.mockReturnValue({ + surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'session-1' }, + promptDeliveryResult: DELIVERED, + structuredSettlement: Promise.resolve({ kind: 'launched' }) + }) + + await expect(resolveCommentsWithAi(hook)).resolves.toBe(true) + + expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith( + expect.objectContaining({ + reviewReply: expect.objectContaining({ + provider: 'github', + repoId: 'repo-1', + prNumber: 42, + resolve: ['T1'], + conversationReply: expect.stringContaining('Fixing') + }) + }) + ) + expect(model.resolveReviewThread).not.toHaveBeenCalled() + expect(model.addPRConversationComment).not.toHaveBeenCalled() + expect(model.addPRReviewCommentReply).not.toHaveBeenCalled() + // No counts toast: the chat says it if a write fails. + expect(mocks.toastSuccess).not.toHaveBeenCalled() + expect(model.setCommentsSelectionClearRequest).toHaveBeenCalledOnce() + expect(model.pendingCommentResolutionRef.current).toBeNull() + }) + + it('writes once from the panel when a terminal agent took the paste', async () => { + const { model, hook } = acknowledgement() + mocks.launchAgentInNewTab.mockReturnValue({ + surface: { kind: 'local-terminal', tabId: 'tab-1' }, + promptDeliveryResult: DELIVERED + }) + + await expect(resolveCommentsWithAi(hook)).resolves.toBe(true) + + await vi.waitFor(() => expect(model.resolveReviewThread).toHaveBeenCalledOnce()) + expect(model.addPRConversationComment).toHaveBeenCalledOnce() + await vi.waitFor(() => expect(mocks.toastSuccess).toHaveBeenCalledOnce()) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/panel-content-rendering.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/panel-content-rendering.test.tsx index 5b587fbe4784..4c965dffe9ba 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/panel-content-rendering.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/panel-content-rendering.test.tsx @@ -127,6 +127,7 @@ describe('checks panel concrete content', () => { commentsSelectionClearRequest: null, conflictDetailsRefreshing: false, consumeClaimedCommentResolutionAfterDeliveryRef: { current: vi.fn() }, + buildLaunchReviewReply: vi.fn(() => undefined), detachedHeadDisplay: null, editingTitle: false, getGitLabProjectRef: vi.fn(() => null), diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx index d1441f09ff42..b0ab024db63f 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx @@ -7,10 +7,13 @@ import { canPostPRReviewThreadReply, checksPanelReviewStableKey, hasPRCommentGroupNeedingReply, + peekPendingPRCommentAiAck, resolvePRReviewReplyThreadId, setPendingPRCommentAiAck, takePendingPRCommentAiAck } from '../pr-comments-ai-launch-ack' +import { buildPRCommentReviewReply } from '../pr-comment-review-reply-spec' +import type { AgentSessionReviewReply } from '../../../../../shared/agent-session-review-reply' import type { ChecksPanelControllerState } from './use-checks-panel-controller-state' import type { ChecksPanelReviewDataState } from './use-checks-panel-review-data' import type { ChecksPanelPollingState } from './use-checks-panel-polling' @@ -301,38 +304,54 @@ export function useChecksPanelAiAcknowledgement(model: ChecksPanelAiAcknowledgem commentResolutionLaunchAcceptedRef ]) - /** Prompt reached the agent: only now may Orca write to the host. */ - const consumeClaimedCommentResolutionAfterDelivery = useCallback((): void => { - const resolution = - claimedCommentResolutionRef.current ?? - takePendingPRCommentAiAck() ?? - pendingCommentResolutionRef.current - claimedCommentResolutionRef.current = null - pendingCommentResolutionRef.current = null - commentResolutionLaunchAcceptedRef.current = false - if (!resolution) { - setCommentResolutionAckBusyNow(false) - return - } - setCommentResolutionAckBusyNow(true) - void resolveSelectedThreadsAfterLaunch(resolution) - .catch((err) => { - console.warn('Failed to resolve/reply on selected review comments after AI launch:', err) - toast.error( - translate( - 'auto.components.right.sidebar.ChecksPanel.495b2f8c4b', - 'Started the agent, but could not resolve or reply on the selected comments.' + /** What a structured chat's launch prompt carries for its host to write once the agent takes it. */ + const buildLaunchReviewReply = useCallback((): AgentSessionReviewReply | undefined => { + const pending = peekPendingPRCommentAiAck() ?? pendingCommentResolutionRef.current + return pending ? buildPRCommentReviewReply(pending, commentsRef.current) : undefined + }, [commentsRef, pendingCommentResolutionRef]) + + /** Prompt reached the agent: only now may Orca write to the host. A structured chat's message + * carries the writes for its host instead, so here they are only handed off. */ + const consumeClaimedCommentResolutionAfterDelivery = useCallback( + (launch?: { structuredChat: boolean }): void => { + const resolution = + claimedCommentResolutionRef.current ?? + takePendingPRCommentAiAck() ?? + pendingCommentResolutionRef.current + claimedCommentResolutionRef.current = null + pendingCommentResolutionRef.current = null + commentResolutionLaunchAcceptedRef.current = false + if (!resolution) { + setCommentResolutionAckBusyNow(false) + return + } + if (launch?.structuredChat) { + clearSentCommentSelection(resolution.reviewContextKey) + setCommentResolutionAckBusyNow(false) + return + } + setCommentResolutionAckBusyNow(true) + void resolveSelectedThreadsAfterLaunch(resolution) + .catch((err) => { + console.warn('Failed to resolve/reply on selected review comments after AI launch:', err) + toast.error( + translate( + 'auto.components.right.sidebar.ChecksPanel.495b2f8c4b', + 'Started the agent, but could not resolve or reply on the selected comments.' + ) ) - ) - }) - .finally(() => setCommentResolutionAckBusyNow(false)) - }, [ - resolveSelectedThreadsAfterLaunch, - setCommentResolutionAckBusyNow, - claimedCommentResolutionRef, - pendingCommentResolutionRef, - commentResolutionLaunchAcceptedRef - ]) + }) + .finally(() => setCommentResolutionAckBusyNow(false)) + }, + [ + clearSentCommentSelection, + resolveSelectedThreadsAfterLaunch, + setCommentResolutionAckBusyNow, + claimedCommentResolutionRef, + pendingCommentResolutionRef, + commentResolutionLaunchAcceptedRef + ] + ) // Why: auto-start can capture a stale callback; always call the latest consumer. const consumeClaimedCommentResolutionAfterDeliveryRef = useRef( consumeClaimedCommentResolutionAfterDelivery @@ -364,6 +383,7 @@ export function useChecksPanelAiAcknowledgement(model: ChecksPanelAiAcknowledgem resolveSelectedThreadsAfterLaunch, handleLaunchAccepted, handleLaunchAborted, + buildLaunchReviewReply, consumeClaimedCommentResolutionAfterDeliveryRef } } diff --git a/src/renderer/src/components/right-sidebar/pr-comment-review-reply-spec.test.ts b/src/renderer/src/components/right-sidebar/pr-comment-review-reply-spec.test.ts new file mode 100644 index 000000000000..b3a624eda425 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/pr-comment-review-reply-spec.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' +import type { PRComment } from '../../../../shared/github/comment-types' +import { MAX_REVIEW_REPLY_TARGETS } from '../../../../shared/agent-session-review-reply' +import { buildPRCommentReviewReply } from './pr-comment-review-reply-spec' +import { PR_COMMENT_AI_FIXING_REPLY } from './pr-comment-fixing-reply-body' +import type { PendingPRCommentAiAck } from './pr-comments-ai-launch-ack' + +function comment(overrides: Partial): PRComment { + return { + id: 1, + author: 'alice', + authorAvatarUrl: '', + body: 'Please update this.', + createdAt: '2026-05-14T00:00:00Z', + url: 'https://github.com/acme/widgets/pull/42#discussion_r1', + ...overrides + } +} + +const TARGET = { repoPath: '/repos/widgets', repoId: 'repo-1', prNumber: 42 } + +/** An open thread to resolve, an outdated thread with a path only, and a conversation comment. */ +function github(): PendingPRCommentAiAck { + return { + reviewContextKey: 'repo-1::42::sha-1', + provider: 'github', + selectedGroups: [ + { + kind: 'thread', + threadId: 'T1', + root: comment({ id: 10, threadId: 'T1', path: 'src/a.ts', isResolved: false }), + replies: [] + }, + { kind: 'standalone', comment: comment({ id: 11, path: 'src/b.ts', line: 4 }) }, + { + kind: 'standalone', + comment: comment({ id: 20, url: 'https://github.com/acme/widgets/pull/42#issuecomment-9' }) + } + ], + githubTarget: { ...TARGET, prRepo: { owner: 'acme', repo: 'widgets' } }, + githubResolveTarget: TARGET + } +} + +describe('the review reply a launch hands a structured chat', () => { + it('resolves what the host can close and replies, in the thread or once on the PR, to the rest', () => { + const live = [comment({ id: 12, threadId: 'T2', path: 'src/b.ts', line: 4 })] + + expect(buildPRCommentReviewReply(github(), live)).toEqual({ + provider: 'github', + repoId: 'repo-1', + prNumber: 42, + prRepo: { owner: 'acme', repo: 'widgets' }, + resolve: ['T1'], + replies: [{ commentId: 11, threadId: 'T2', path: 'src/b.ts', line: 4 }], + replyBody: PR_COMMENT_AI_FIXING_REPLY, + conversationReply: expect.stringContaining('Fixing') + }) + }) + + it('only resolves on GitLab', () => { + const ack: PendingPRCommentAiAck = { + ...github(), + provider: 'gitlab', + gitlabTarget: { repoPath: '/repos/widgets', repoId: 'repo-1', iid: 8 } + } + + expect(buildPRCommentReviewReply(ack, [])).toEqual({ + provider: 'gitlab', + repoId: 'repo-1', + iid: 8, + resolve: ['T1'] + }) + }) + + it('leaves off a reply past its bounds rather than fail the launch', () => { + const many = Array.from({ length: MAX_REVIEW_REPLY_TARGETS + 1 }, (_, index) => ({ + kind: 'thread' as const, + threadId: `T${index}`, + root: comment({ id: index + 1, threadId: `T${index}`, path: 'a.ts', isResolved: false }), + replies: [] + })) + + expect(buildPRCommentReviewReply({ ...github(), selectedGroups: many }, [])).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/right-sidebar/pr-comment-review-reply-spec.ts b/src/renderer/src/components/right-sidebar/pr-comment-review-reply-spec.ts new file mode 100644 index 000000000000..ecca5fc66ef1 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/pr-comment-review-reply-spec.ts @@ -0,0 +1,93 @@ +// The review reply a "Resolve comments with AI" launch hands a structured chat: what the checks +// panel would resolve and post after the paste, planned here and run by the host once the agent +// takes the message. + +import type { PRComment } from '../../../../shared/github/comment-types' +import { + AgentSessionReviewReplySchema, + type AgentSessionReviewReply +} from '../../../../shared/agent-session-review-reply' +import { isResolvablePRCommentGroup } from '../pr-comments-resolution-prompt' +import { + buildPRCommentBatchConversationReplyBody, + PR_COMMENT_AI_FIXING_REPLY +} from './pr-comment-fixing-reply-body' +import { + canPostPRReviewThreadReply, + getPRCommentGroupReplyTarget, + getPRCommentGroupsNeedingReply, + resolvePRReviewReplyThreadId, + type PendingPRCommentAiAck +} from './pr-comments-ai-launch-ack' + +/** Undefined when the review has nothing a host can write to, or the spec breaks its bounds. */ +export function buildPRCommentReviewReply( + ack: PendingPRCommentAiAck, + liveComments: readonly PRComment[] +): AgentSessionReviewReply | undefined { + const resolve = ack.selectedGroups.filter(isResolvablePRCommentGroup).map((g) => g.threadId) + const spec = reviewReplyFor(ack, resolve, liveComments) + if (!spec) { + return undefined + } + const parsed = AgentSessionReviewReplySchema.safeParse(spec) + if (!parsed.success) { + console.warn('Review reply left off the launch prompt:', parsed.error.message) + return undefined + } + return parsed.data +} + +function reviewReplyFor( + ack: PendingPRCommentAiAck, + resolve: string[], + liveComments: readonly PRComment[] +): AgentSessionReviewReply | undefined { + if (ack.provider === 'gitlab') { + // GitLab acknowledges by resolving only, as the panel does after a paste. + return ack.gitlabTarget + ? { provider: 'gitlab', repoId: ack.gitlabTarget.repoId, iid: ack.gitlabTarget.iid, resolve } + : undefined + } + const target = ack.githubResolveTarget + if (ack.provider !== 'github' || !target) { + return undefined + } + // Replies need the PR's repository, which only the reply target is sure to name. + const replyTargets = ack.githubTarget + ? getPRCommentGroupsNeedingReply(ack.selectedGroups).map(getPRCommentGroupReplyTarget) + : [] + const inThread = replyTargets.filter(canPostPRReviewThreadReply) + const conversation = replyTargets.filter((comment) => !canPostPRReviewThreadReply(comment)) + const prRepo = ack.githubTarget?.prRepo ?? target.prRepo + return { + provider: 'github', + repoId: target.repoId, + prNumber: target.prNumber, + ...(prRepo + ? { + prRepo: { + owner: prRepo.owner, + repo: prRepo.repo, + ...(prRepo.host ? { host: prRepo.host } : {}) + } + } + : {}), + resolve, + replies: inThread.map((comment) => { + const threadId = + resolvePRReviewReplyThreadId({ parent: comment, existingComments: liveComments }) ?? + comment.threadId + return { + commentId: comment.id, + ...(threadId ? { threadId } : {}), + ...(comment.path ? { path: comment.path } : {}), + ...(comment.line !== undefined ? { line: comment.line } : {}) + } + }), + replyBody: PR_COMMENT_AI_FIXING_REPLY, + ...(conversation.length > 0 + ? { conversationReply: buildPRCommentBatchConversationReplyBody(conversation) } + : {}) + } +} diff --git a/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts b/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts index 8f21d3c79206..d3079ca74dc4 100644 --- a/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts +++ b/src/renderer/src/components/right-sidebar/pr-comments-ai-launch-ack.ts @@ -342,6 +342,11 @@ export function setPendingPRCommentAiAck(payload: PendingPRCommentAiAck): void { pendingAiCommentAck = payload } +/** The unclaimed payload, left in place: a launch plans its review reply from it. */ +export function peekPendingPRCommentAiAck(): PendingPRCommentAiAck | null { + return pendingAiCommentAck +} + export function takePendingPRCommentAiAck(): PendingPRCommentAiAck | null { const payload = pendingAiCommentAck pendingAiCommentAck = null diff --git a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts index 2f5c044ab1d7..f273076b3c9f 100644 --- a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts @@ -1,3 +1,4 @@ +import type { AgentSessionReviewReply } from '../../../../shared/agent-session-review-reply' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' import type { GlobalSettings } from '../../../../shared/global-settings-types' @@ -51,7 +52,12 @@ type RunSourceControlAgentActionStartArgs = { onLaunchAccepted?: () => void /** Fires when a launch that already reported onLaunchAccepted failed to deliver its prompt. */ onLaunchAborted?: () => void - onLaunched?: () => void + /** `structuredChat`: the launch opened a structured chat, whose message carries any review + * reply, so the caller writes nothing to the review itself. */ + onLaunched?: (launch: { structuredChat: boolean }) => void + /** Planned at start: what a structured chat's host writes on the review once its agent takes + * the prompt. A terminal agent has no message to carry it. */ + reviewReply?: () => AgentSessionReviewReply | undefined onClose: () => void } @@ -76,9 +82,11 @@ export async function runSourceControlAgentActionStart({ onLaunchAccepted, onLaunchAborted, onLaunched, + reviewReply, onClose }: RunSourceControlAgentActionStartArgs): Promise { let launched = false + let structuredChat = false let launchFailureNotified = false let launchAcceptedNotified = false // Why: `undefined` is what makes the launch fall back to the global Agents arguments; @@ -101,6 +109,7 @@ export async function runSourceControlAgentActionStart({ notifyLaunchAccepted() } } else if (worktreeId) { + const plannedReviewReply = reviewReply?.() const result = launchAgentInNewTab({ agent: selectedAgent, worktreeId, @@ -109,9 +118,11 @@ export async function runSourceControlAgentActionStart({ agentArgs: launchAgentArgs, promptDelivery, launchPlatform, - launchSource + launchSource, + ...(plannedReviewReply ? { reviewReply: plannedReviewReply } : {}) }) launched = Boolean(result) + structuredChat = Boolean(result?.structuredSettlement) if (result?.surface.kind === 'local-terminal') { focusTerminalTabSurface(result.surface.tabId) } @@ -171,7 +182,7 @@ export async function runSourceControlAgentActionStart({ console.error('onSaveAgentDefault failed', error) } } - onLaunched?.() + onLaunched?.({ structuredChat }) onClose() return true } diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts index 60e7a45bb1bb..d6e1fcc45de1 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionDialog.ts @@ -44,6 +44,7 @@ export function useSourceControlAgentActionDialog({ onLaunchAccepted, onLaunchAborted, onLaunched, + reviewReply, onStart }: SourceControlAgentActionDialogProps): UseSourceControlAgentActionDialogResult { const settings = useAppStore((state) => state.settings) @@ -212,6 +213,7 @@ export function useSourceControlAgentActionDialog({ onLaunchAccepted, onLaunchAborted, onLaunched, + reviewReply, onClose: closeDialog }) diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts index 2209c1621fef..2ba4009703a1 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts @@ -1,3 +1,4 @@ +import type { AgentSessionReviewReply } from '../../../../shared/agent-session-review-reply' import { useCallback, useRef, useState } from 'react' import type { LaunchSource } from '../../../../shared/telemetry-events' import type { @@ -50,7 +51,8 @@ type UseSourceControlAgentActionStartArgs = { ) => void | Promise onLaunchAccepted?: () => void onLaunchAborted?: () => void - onLaunched?: () => void + onLaunched?: (launch: { structuredChat: boolean }) => void + reviewReply?: () => AgentSessionReviewReply | undefined onClose: () => void } @@ -95,6 +97,7 @@ export function useSourceControlAgentActionStart({ onLaunchAccepted, onLaunchAborted, onLaunched, + reviewReply, onClose }: UseSourceControlAgentActionStartArgs): UseSourceControlAgentActionStartResult { const [deliveryPlan, setDeliveryPlan] = useState({ @@ -174,6 +177,7 @@ export function useSourceControlAgentActionStart({ onLaunchAccepted, onLaunchAborted, onLaunched, + reviewReply, onClose: () => { resetDeliveryPlan() onClose() @@ -198,6 +202,7 @@ export function useSourceControlAgentActionStart({ onLaunchAborted, onLaunchAccepted, onLaunched, + reviewReply, onSaveAgentDefault, onStart, promptDelivery, diff --git a/src/renderer/src/lib/agent-session-launch-plan.ts b/src/renderer/src/lib/agent-session-launch-plan.ts index 7e72412e4fb6..733ffc3414e0 100644 --- a/src/renderer/src/lib/agent-session-launch-plan.ts +++ b/src/renderer/src/lib/agent-session-launch-plan.ts @@ -19,11 +19,14 @@ import { type StructuredAgentLaunchHooks, type StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' +import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' import type { StructuredAgentLaunchOptions } from '@/lib/structured-agent-session-launch' export type AgentSessionLaunchRequest = AgentLaunchRouteArgs & { resumeFrom?: StructuredAgentSessionResumeSource onPromptDelivered?: () => void + /** See `StructuredAgentLaunchOptions.reviewReply`; a terminal launch ignores it. */ + reviewReply?: AgentSessionReviewReply } /** @@ -39,6 +42,7 @@ export type AgentSessionLaunchVerdict = { promptDelivery?: NativeChatLaunchPromptDelivery resumeFrom?: StructuredAgentSessionResumeSource onPromptDelivered?: () => void + reviewReply?: AgentSessionReviewReply } export type AgentSessionStructuredFeasibilityRequest = AgentLaunchRouteArgs & { @@ -70,7 +74,8 @@ function structuredLaunchOptions(verdict: AgentSessionLaunchVerdict): Structured ...(verdict.prompt !== undefined ? { prompt: verdict.prompt } : {}), ...(verdict.promptDelivery ? { promptDelivery: verdict.promptDelivery } : {}), ...(verdict.resumeFrom ? { resumeFrom: verdict.resumeFrom } : {}), - ...(verdict.onPromptDelivered ? { onPromptDelivered: verdict.onPromptDelivered } : {}) + ...(verdict.onPromptDelivered ? { onPromptDelivered: verdict.onPromptDelivered } : {}), + ...(verdict.reviewReply ? { reviewReply: verdict.reviewReply } : {}) } } @@ -135,6 +140,7 @@ export function planAgentSessionLaunch( ...(request.prompt !== undefined ? { prompt: request.prompt } : {}), ...(request.promptDelivery ? { promptDelivery: request.promptDelivery } : {}), ...(request.resumeFrom ? { resumeFrom: request.resumeFrom } : {}), - ...(request.onPromptDelivered ? { onPromptDelivered: request.onPromptDelivered } : {}) + ...(request.onPromptDelivered ? { onPromptDelivered: request.onPromptDelivered } : {}), + ...(request.reviewReply ? { reviewReply: request.reviewReply } : {}) }) } diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index ce28d3c8ad18..7a8559a07b91 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -1,3 +1,4 @@ +import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' import { useAppStore } from '@/store' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' import { planLaunchAgentStartupPrompt } from '@/lib/launch-agent-startup-prompt-plan' @@ -52,6 +53,8 @@ export type LaunchAgentInNewTabArgs = { launchPlatform?: NodeJS.Platform /** Called after the prompt is actually delivered to the agent input path. */ onPromptDelivered?: () => void + /** Rides a structured chat's launch prompt (see `StructuredAgentLaunchOptions.reviewReply`). */ + reviewReply?: AgentSessionReviewReply /** * Called before `onPromptDelivered` when the paste was written without ever observing the * agent's composer, so the launch cannot claim the prompt arrived. Fires only on the @@ -115,6 +118,7 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent launchPlatform, onPromptDelivered, onPromptDeliveryUnconfirmed, + reviewReply, agentSessionLaunchPlan, pendingActivationSpawn, beforeSurfaceOpen @@ -209,7 +213,8 @@ function launchAgentInNewTabInternal(args: LaunchAgentInNewTabArgs): LaunchAgent promptDelivery: viewModePromptDelivery, tuiCustomization: { cwd: initialCwd }, initialSessionOptions: startupPlan.sessionOptions, - onPromptDelivered + onPromptDelivered, + ...(reviewReply ? { reviewReply } : {}) }) if (plan?.route === 'structured-native-chat') { const structured = launchAgentInStructuredNewTab({ diff --git a/src/renderer/src/lib/structured-agent-session-launch-callers.ts b/src/renderer/src/lib/structured-agent-session-launch-callers.ts index db3f930d0fbe..ffda826c2403 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-callers.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-callers.ts @@ -1,17 +1,32 @@ import { settleStructuredAgentLaunchPrompt } from '@/lib/structured-agent-session-launch-prompt' +import { enqueueStructuredAgentSessionLaunchPrompt } from '@/components/native-chat/structured-agent-session-outbox-storage' import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-session-launch-prompt' import type { StructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox' import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create' +import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' export type StructuredAgentLaunchOptions = { prompt?: string promptDelivery?: 'auto-submit' | 'submit-after-ready' | 'draft' onPromptDelivered?: () => void + /** What Orca does on the review once the agent takes the prompt; rides the prompt's message. */ + reviewReply?: AgentSessionReviewReply /** Adopt an existing provider conversation instead of starting a fresh one. Part of the launch's * identity, not a preference — see `launchIdentity`. */ resumeFrom?: StructuredAgentSessionResumeSource } +/** Queues a launch's prompt on its chat's outbox, carrying any review reply onto its message. */ +export function stageStructuredLaunchPrompt( + sessionId: string, + text: string, + options: StructuredAgentLaunchOptions +): StructuredAgentSessionOutboxEntry | null { + return text + ? enqueueStructuredAgentSessionLaunchPrompt(sessionId, text, options.reviewReply) + : null +} + export type StructuredLaunchCaller = { promptDeliveryResult?: Promise } diff --git a/src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts b/src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts index 3a82f0b390c2..f5c4d876c667 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-join-delivery.test.ts @@ -176,3 +176,40 @@ describe('coalesced launch delivery mode', () => { ) }) }) + +describe('a launch prompt with a review reply', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + mocks.rendererTabs = {} + mocks.listeners.clear() + mocks.callStructuredAgentSession.mockResolvedValue({ ok: true, page: { fence: 1 } }) + }) + + it('carries it on the message the launch sends', async () => { + const reviewReply = { + provider: 'gitlab' as const, + repoId: 'repo-1', + iid: 8, + resolve: ['discussion-1'] + } + const intent = launchIntent('wt-review-reply', 'review-reply-session') + mocks.createIntent.mockReturnValueOnce(intent) + mocks.launch.mockResolvedValue({ sessionId: intent.sessionId, fence: 1 }) + vi.mocked(refreshLocalStructuredSessionTabs).mockResolvedValue([ + publishedSnapshot('wt-review-reply', intent.sessionId) + ]) + + startStructuredAgentLaunch('wt-review-reply', 'codex', { + prompt: 'Resolve these', + promptDelivery: 'auto-submit', + reviewReply + }) + await flushLaunchSettlement() + + const send = mocks.callStructuredAgentSession.mock.calls.find( + (call) => call[1] === 'agentSession.send' + ) + expect(send?.[2]).toMatchObject({ reviewReply }) + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-launch.ts b/src/renderer/src/lib/structured-agent-session-launch.ts index 10a37d46c4c3..6356d6f04218 100644 --- a/src/renderer/src/lib/structured-agent-session-launch.ts +++ b/src/renderer/src/lib/structured-agent-session-launch.ts @@ -6,10 +6,7 @@ import { retryStructuredAgentSessionLaunchIntent, StructuredAgentSessionCreateRefusalError } from '@/lib/launch-structured-agent-session' -import { - discardStructuredAgentSessionLaunchOutbox, - enqueueStructuredAgentSessionLaunchPrompt -} from '@/components/native-chat/structured-agent-session-outbox-storage' +import { discardStructuredAgentSessionLaunchOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage' import { launchAndReconcile, reconcileUnknownLaunch, @@ -21,6 +18,7 @@ import { createStructuredLaunchCallerGroup, releaseStructuredLaunchCallerAfterUnknownOutcome, settleStructuredLaunchCallers, + stageStructuredLaunchPrompt, structuredLaunchCallersHavePendingWork, type StructuredAgentLaunchOptions, type StructuredLaunchCaller @@ -207,9 +205,7 @@ function structuredAgentLaunchState( const joined = joinLaunchDelivery(options, existing.promptDelivery) // Why: failed launches keep their draft/outbox, so a retry must not stage the same prompt twice. const text = retrying ? '' : outboxPromptText(joined) - const stagedPrompt = text - ? enqueueStructuredAgentSessionLaunchPrompt(existing.intent.sessionId, text) - : null + const stagedPrompt = stageStructuredLaunchPrompt(existing.intent.sessionId, text, joined) if (!retrying) { launchDraft.seedStructuredAgentLaunchDraft(existing.intent.sessionId, agent, joined) } @@ -233,9 +229,7 @@ function structuredAgentLaunchState( ? createStructuredAgentSessionLaunchIntent(worktreeId, agent, options.resumeFrom) : createStructuredAgentSessionLaunchIntent(worktreeId, agent) const text = outboxPromptText(options) - const stagedPrompt = text - ? enqueueStructuredAgentSessionLaunchPrompt(intent.sessionId, text) - : null + const stagedPrompt = stageStructuredLaunchPrompt(intent.sessionId, text, options) launchDraft.seedStructuredAgentLaunchDraft(intent.sessionId, agent, options) const callers = createStructuredLaunchCallerGroup() const state: StructuredLaunchState = { diff --git a/src/shared/agent-session-review-reply.ts b/src/shared/agent-session-review-reply.ts index 414a4659741b..059818cd7714 100644 --- a/src/shared/agent-session-review-reply.ts +++ b/src/shared/agent-session-review-reply.ts @@ -54,6 +54,14 @@ export const AgentSessionReviewReplySchema = z export type AgentSessionReviewReply = z.infer +/** `{ reviewReply }` for a well-formed stored spec, else nothing: for spreading into a copy. */ +export function agentSessionReviewReplyField(value: unknown): { + reviewReply?: AgentSessionReviewReply +} { + const reviewReply = readAgentSessionReviewReply(value) + return reviewReply ? { reviewReply } : {} +} + /** A stored spec as a reader meets it; undefined for anything malformed, which then runs nothing. */ export function readAgentSessionReviewReply(value: unknown): AgentSessionReviewReply | undefined { const parsed = AgentSessionReviewReplySchema.safeParse(value) diff --git a/src/shared/structured-agent-session-outbox-review-reply.test.ts b/src/shared/structured-agent-session-outbox-review-reply.test.ts new file mode 100644 index 000000000000..642de5667386 --- /dev/null +++ b/src/shared/structured-agent-session-outbox-review-reply.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import type { AgentSessionReviewReply } from './agent-session-review-reply' +import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' +import { + createStructuredAgentSessionOutboxEntry, + parseStructuredAgentSessionOutboxEntry, + requeueStructuredAgentSessionSendRefusal, + structuredAgentSessionSendMutation, + type StructuredAgentSessionOutboxEntry +} from './structured-agent-session-outbox' + +const REVIEW_REPLY: AgentSessionReviewReply = { + provider: 'github', + repoId: 'repo-1', + prNumber: 42, + resolve: ['thread-1'], + replies: [{ commentId: 7, threadId: 'thread-7' }], + replyBody: 'Fixing. Will be in the next commit' +} + +function launchPrompt(): StructuredAgentSessionOutboxEntry { + return { + ...createStructuredAgentSessionOutboxEntry({ + clientMessageId: '1800000000000-00000000000000000000000000000001', + sessionId: 'session-1', + text: 'Resolve these', + attachments: [], + queuedAt: 1 + }), + source: 'launch', + reviewReply: REVIEW_REPLY + } +} + +describe("a launch prompt's review reply on the outbox", () => { + it('survives the stored copy, and a malformed one is dropped, never the message', () => { + const stored: unknown = JSON.parse(JSON.stringify(launchPrompt())) + expect(parseStructuredAgentSessionOutboxEntry(stored, 'session-1')).toMatchObject({ + reviewReply: REVIEW_REPLY + }) + const malformed: unknown = { ...launchPrompt(), reviewReply: { provider: 'github' } } + const parsed = parseStructuredAgentSessionOutboxEntry(malformed, 'session-1') + expect(parsed).not.toBeNull() + expect(parsed).not.toHaveProperty('reviewReply') + }) + + it('rides every send of the entry, in the fingerprint the host digests', () => { + const mutation = structuredAgentSessionSendMutation(launchPrompt(), 2) + expect(mutation.reviewReply).toEqual(REVIEW_REPLY) + expect(mutation.envelope.payloadFingerprint).toBe( + structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { body: mutation.body, reviewReply: REVIEW_REPLY } + }) + ) + }) + + it('stays on the entry when a refused send goes out again under a new id', () => { + const requeued = requeueStructuredAgentSessionSendRefusal( + launchPrompt(), + { kind: 'refused', code: 'agent_session_operation_expired' }, + () => '1800000000000-00000000000000000000000000000002' + ) + expect(requeued).toMatchObject({ + clientMessageId: '1800000000000-00000000000000000000000000000002', + reviewReply: REVIEW_REPLY + }) + }) +}) diff --git a/src/shared/structured-agent-session-outbox.ts b/src/shared/structured-agent-session-outbox.ts index e6c4e26f4bcb..3cb5f8718a0f 100644 --- a/src/shared/structured-agent-session-outbox.ts +++ b/src/shared/structured-agent-session-outbox.ts @@ -13,6 +13,8 @@ import { import type { AgentSessionMutationEnvelope } from './agent-session-wire' import { structuredAgentSessionPayloadFingerprint } from './structured-agent-session-mutation' import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' +import type { AgentSessionReviewReply } from './agent-session-review-reply' +import { agentSessionReviewReplyField } from './agent-session-review-reply' import { parseStructuredAgentSessionOutboxQueueFields } from './structured-agent-session-outbox-delivery' /** `rejected`: the host settled the send as not delivered. The drain never sends it again on its @@ -34,6 +36,9 @@ export type StructuredAgentSessionOutboxEntry = { lastAttemptAt: number | null retryAfterUnknownSubmittedAt: number | null source?: 'launch' + /** What Orca does on the review once the agent takes this launch prompt; every send of the + * entry carries it, so no retried or rotated id drops it. */ + reviewReply?: AgentSessionReviewReply /** A Stop landed after this queue send went out: only the user's Retry sends it again, never the * drain, the unconfirmed probe or an owner change, which would start a turn the user stopped. */ outlivedStop?: true @@ -297,6 +302,7 @@ export function parseStructuredAgentSessionOutboxEntry( ? entry.retryAfterUnknownSubmittedAt : null, ...(entry.source === 'launch' ? { source: 'launch' as const } : {}), + ...agentSessionReviewReplyField(entry.reviewReply), ...parseStructuredAgentSessionOutboxQueueFields(entry), ...(lastFailure ? { lastFailure } : {}) } @@ -306,6 +312,7 @@ export type StructuredAgentSessionSendMutation = { envelope: AgentSessionMutationEnvelope body: AgentJournalMessageItem delivery?: 'queue-if-active' + reviewReply?: AgentSessionReviewReply } /** The `agentSession.send` arguments an entry stands for. Typed rather than wire-shaped so a host @@ -314,9 +321,10 @@ export function structuredAgentSessionSendMutation( entry: StructuredAgentSessionOutboxEntry, expectedRuntimeFence: number ): StructuredAgentSessionSendMutation { - // `delivery` joins the OPERATION fingerprint exactly as the host digests it; never the body's. + // `delivery` and `reviewReply` join the OPERATION fingerprint exactly as the host digests them. const delivery = entry.sentDelivery ?? undefined - const fields = { body: entry.body, ...(delivery ? { delivery } : {}) } + const plain = { body: entry.body, ...(delivery ? { delivery } : {}) } + const fields = entry.reviewReply ? { ...plain, reviewReply: entry.reviewReply } : plain return { envelope: { sessionId: entry.sessionId, From 48b2995eb072da3f8c5486f2c534e46b463328c2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 09:56:56 -0700 Subject: [PATCH 60/84] refactor(native-chat): the record store's commit listeners and the coordinator test's RPC caller in their own modules Both files went over their line limits once this PR added the at-rest create and the attach helper. The store's death-evidence and first-record listeners, and the draft watch that feeds them, move to AgentSessionRecordStoreCommitListeners; the coordinator-mail suite's numbered RPC request and call move to a test fixture. No behavior change. --- ...t-session-record-store-commit-listeners.ts | 59 +++++++++++++++++++ .../runtime/agent-session-record-store.ts | 41 ++++--------- .../structured-chat-coordinator-mail.test.ts | 37 +----------- ...tured-chat-coordinator-rpc.test-fixture.ts | 44 ++++++++++++++ 4 files changed, 117 insertions(+), 64 deletions(-) create mode 100644 src/main/runtime/agent-session-record-store-commit-listeners.ts create mode 100644 src/main/runtime/structured-chat-coordinator-rpc.test-fixture.ts diff --git a/src/main/runtime/agent-session-record-store-commit-listeners.ts b/src/main/runtime/agent-session-record-store-commit-listeners.ts new file mode 100644 index 000000000000..8982ae159cf6 --- /dev/null +++ b/src/main/runtime/agent-session-record-store-commit-listeners.ts @@ -0,0 +1,59 @@ +// Who the record store tells, once a transaction commits, of what it changed: each session it wrote +// a proof of death for, and its first chat. Kept apart from the transaction itself, which only asks +// it to watch the draft it applies. + +import type { AgentSessionStoreState } from './agent-session-record-store-file' + +export class AgentSessionRecordStoreCommitListeners { + private readonly deathEvidence = new Set<(sessionId: string) => void>() + private readonly firstRecord = new Set<() => void>() + + /** Every transition that writes a proof of death lands here. Must not throw. */ + onDeathEvidence(listener: (sessionId: string) => void): () => void { + this.deathEvidence.add(listener) + return () => this.deathEvidence.delete(listener) + } + + /** Must not throw. */ + onFirstRecord(listener: () => void): () => void { + this.firstRecord.add(listener) + return () => this.firstRecord.delete(listener) + } + + /** Applies one transaction's change to its draft, noting what to tell once it commits. */ + watch( + holdsRecords: () => boolean, + draft: AgentSessionStoreState, + apply: (draft: AgentSessionStoreState) => T + ): { result: T; notify: () => void } { + const heldBefore = holdsRecords() + if (this.deathEvidence.size === 0) { + return { + result: apply(draft), + notify: () => this.notifyFirstRecord(heldBefore, holdsRecords) + } + } + const before = new Map( + [...draft.records].map(([id, record]) => [id, record.lease.deathEvidence]) + ) + const result = apply(draft) + const proven = [...draft.records] + .filter(([id, { lease }]) => lease.deathEvidence && lease.deathEvidence !== before.get(id)) + .map(([id]) => id) + return { + result, + notify: () => { + for (const sessionId of proven) { + this.deathEvidence.forEach((listener) => listener(sessionId)) + } + this.notifyFirstRecord(heldBefore, holdsRecords) + } + } + } + + private notifyFirstRecord(heldBefore: boolean, holdsRecords: () => boolean): void { + if (!heldBefore && holdsRecords()) { + this.firstRecord.forEach((listener) => listener()) + } + } +} diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index bf850d32c7c9..ae5a0de96668 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -70,6 +70,7 @@ import { setAgentSessionTabVisibility, showAgentSessionTabs } from './agent-sess import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import { loadAgentSessionStoreRows } from './agent-session-record-rows' import { AgentSessionStoreTransactions } from './agent-session-store-transactions' +import { AgentSessionRecordStoreCommitListeners } from './agent-session-record-store-commit-listeners' import { commitAgentSessionAtRestCreate, type AgentSessionAtRestCreateRequest, @@ -80,8 +81,7 @@ export const AGENT_SESSION_LEASE_TTL_MS = 30_000, AGENT_SESSION_LEASE_RENEW_INTERVAL_MS = 10_000 export class AgentSessionRecordStore { - private readonly deathEvidenceListeners = new Set<(sessionId: string) => void>() - private readonly firstRecordListeners = new Set<() => void>() + private readonly commitListeners = new AgentSessionRecordStoreCommitListeners() private constructor( private readonly transactions: AgentSessionStoreTransactions, @@ -341,16 +341,12 @@ export class AgentSessionRecordStore { /** Told, once committed, of each session a transaction wrote a proof of death for — whichever * transition wrote it, since every one lands here. Must not throw. */ - onDeathEvidence(listener: (sessionId: string) => void): () => void { - this.deathEvidenceListeners.add(listener) - return () => this.deathEvidenceListeners.delete(listener) - } + onDeathEvidence = (listener: (sessionId: string) => void): (() => void) => + this.commitListeners.onDeathEvidence(listener) /** Told, once committed, when the store records its first chat. Must not throw. */ - onFirstRecord(listener: () => void): () => void { - this.firstRecordListeners.add(listener) - return () => this.firstRecordListeners.delete(listener) - } + onFirstRecord = (listener: () => void): (() => void) => + this.commitListeners.onFirstRecord(listener) /** Serializes every mutation. `apply` changes only the draft it is given; readers see the change * once its rows have committed. */ @@ -358,28 +354,13 @@ export class AgentSessionRecordStore { apply: (draft: AgentSessionStoreState) => T, options?: { inMemoryWhenReadOnly?: boolean } ): Promise => { - let proven: string[] = [] - let heldBefore = true + let notify = (): void => {} const result = await this.transactions.transact((draft) => { - heldBefore = this.holdsRecords() - if (this.deathEvidenceListeners.size === 0) { - return apply(draft) - } - const before = new Map( - [...draft.records].map(([id, record]) => [id, record.lease.deathEvidence]) - ) - const applied = apply(draft) - proven = [...draft.records] - .filter(([id, { lease }]) => lease.deathEvidence && lease.deathEvidence !== before.get(id)) - .map(([id]) => id) - return applied + const watched = this.commitListeners.watch(this.holdsRecords, draft, apply) + notify = watched.notify + return watched.result }, options) - for (const sessionId of proven) { - this.deathEvidenceListeners.forEach((listener) => listener(sessionId)) - } - if (!heldBefore && this.holdsRecords()) { - this.firstRecordListeners.forEach((listener) => listener()) - } + notify() return result } } diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index 51ccdb71fa79..8e4f8c1869d3 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -11,7 +11,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import { agentJournalSubmissionKey } from '../../shared/agent-session-journal-item-key' import { computeAgentSessionPayloadFingerprint } from '../../shared/agent-session-mutation-envelope' -import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version' import { AgentSessionAcquisitionRefusal, AgentSessionPreSpawnError @@ -26,15 +25,15 @@ import { OrchestrationDb } from './orchestration/db' import { localOrchestrationCliCommand } from './orchestration/cli-command' import { formatMessagePointer } from './orchestration/formatter' import { currentRunCoordinatorOrcaSessionId } from './orchestration/db/runs/run-coordinator-orca-session' -import type { RpcRequest } from './rpc/core' import { RpcDispatcher } from './rpc/dispatcher' import { ORCHESTRATION_METHODS } from './rpc/methods/orchestration' -import { idOf, isRecord, resultOf } from './rpc/orchestration-session-caller-test-fixture' +import { idOf, isRecord } from './rpc/orchestration-session-caller-test-fixture' import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' import { createCoordinatorMailObservationClock } from './structured-chat-coordinator-observation-clock.test-fixture' +import { createCoordinatorRpcCaller } from './structured-chat-coordinator-rpc.test-fixture' import { attachParams, fakeCodex, @@ -57,39 +56,9 @@ let runtime: OrcaRuntimeService let db: OrchestrationDb let host: StructuredAgentSessionHost let dispatcher: RpcDispatcher -let requests = 0 const observationClock = createCoordinatorMailObservationClock(() => host, COORDINATOR) -function request( - method: string, - params: Record, - options: { sessionId?: string } = {} -): RpcRequest { - requests += 1 - return { - id: `rpc-${requests}`, - authToken: 'test', - method, - params, - orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, - orchestrationRequestId: `req-${requests}`, - ...(options.sessionId - ? { orchestrationCompatibilityEvidence: { agentSessionId: options.sessionId } } - : {}) - } -} - -async function call( - method: string, - params: Record, - options?: { sessionId?: string } -): Promise> { - const response = await dispatcher.dispatch(request(method, params, options)) - if (!response.ok) { - throw new Error(`${method} failed: ${JSON.stringify(response)}`) - } - return resultOf(response) -} +const { request, call } = createCoordinatorRpcCaller(() => dispatcher) async function openChat(sessionId: string): Promise { const opened = await attachForTests(host, { callerKey: 'test-surface' }, attachParams(sessionId)) diff --git a/src/main/runtime/structured-chat-coordinator-rpc.test-fixture.ts b/src/main/runtime/structured-chat-coordinator-rpc.test-fixture.ts new file mode 100644 index 000000000000..7479e0400b13 --- /dev/null +++ b/src/main/runtime/structured-chat-coordinator-rpc.test-fixture.ts @@ -0,0 +1,44 @@ +// The orchestration RPC surface as the coordinator-mail suite calls it: each request numbered, +// under the current contract, naming the caller's agent session as its compatibility evidence. + +import { ORCHESTRATION_CONTRACT_VERSION } from '../../shared/protocol-version' +import type { RpcRequest } from './rpc/core' +import type { RpcDispatcher } from './rpc/dispatcher' +import { resultOf } from './rpc/orchestration-session-caller-test-fixture' + +export function createCoordinatorRpcCaller(dispatcher: () => RpcDispatcher) { + let requests = 0 + + function request( + method: string, + params: Record, + options: { sessionId?: string } = {} + ): RpcRequest { + requests += 1 + return { + id: `rpc-${requests}`, + authToken: 'test', + method, + params, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: `req-${requests}`, + ...(options.sessionId + ? { orchestrationCompatibilityEvidence: { agentSessionId: options.sessionId } } + : {}) + } + } + + async function call( + method: string, + params: Record, + options?: { sessionId?: string } + ): Promise> { + const response = await dispatcher().dispatch(request(method, params, options)) + if (!response.ok) { + throw new Error(`${method} failed: ${JSON.stringify(response)}`) + } + return resultOf(response) + } + + return { request, call } +} From ec979b4789bd0331b27d5fa50b5536f2c6ef5c72 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:12:31 -0700 Subject: [PATCH 61/84] fix(native-chat): a review reply's receipt is written in its chat's own lane The receipt append now queues in the host's per-session serialize, so it lands after whatever the lane is running and at the fence the chat holds then, never under a start or close that moves it. The run that leads to it was started off a publish, so no task in the lane waits on it. --- ...tructured-agent-session-client-delivery.ts | 5 +++- .../structured-agent-session-host.ts | 1 + ...tured-agent-session-review-replies.test.ts | 24 ++++++++++++++++ ...structured-agent-session-review-replies.ts | 28 +++++++++++++------ 4 files changed, 48 insertions(+), 10 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts index 1dca654666ab..50b44f7144c7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-client-delivery.ts @@ -34,6 +34,8 @@ export class StructuredAgentSessionClientDelivery { private readonly sessions: Map, now: () => number, private readonly deps: () => StructuredAgentSessionHostDeps, + /** The host's per-session lane, which a review reply's receipt is written in. */ + serialize: (sessionId: string, task: () => Promise) => Promise, private readonly onJournalActivity?: (sessionId: string) => void, onAgentStarted?: (sessionId: string) => void, /** A session's child records changed; the chat strip republishes from them. */ @@ -57,7 +59,8 @@ export class StructuredAgentSessionClientDelivery { this.waitForSendSettlement = this.sendSettlement.wait this.reviewReplies = createStructuredAgentSessionHostReviewReplies({ deps, - journal: (sessionId) => sessions.get(sessionId)?.journal + journal: (sessionId) => sessions.get(sessionId)?.journal, + serialize }) this.subscribers = new AgentSessionSubscribers({ readCommands: (sessionId) => this.readCommands(sessionId), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 9915a3eebdbe..418734cb6821 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -81,6 +81,7 @@ export class StructuredAgentSessionHost { this.sessions, () => this.now(), () => this.deps, + (sessionId, task) => this.serialize(sessionId, task), (sessionId) => this.queued.onJournalActivity(sessionId), (sessionId) => this.restartResume.onAgentStarted(sessionId), (sessionId) => this.backgroundTasks.publish(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts index c38d0beee249..da3286a88395 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts @@ -401,6 +401,30 @@ describe('a launch prompt with a review reply', () => { }) }) +describe("a review reply's receipt", () => { + it("is written in the chat's own lane, after the work already in it", async () => { + const writesDone = Promise.withResolvers() + review.addRepoIssueComment.mockImplementationOnce(async () => { + await writesDone.promise + return { ok: true, comment: postedComment('summary') } + }) + const id = await launchPrompt() + await handedOver(id) + await accept(id) + await eventually(() => expect(review.addRepoIssueComment).toHaveBeenCalled()) + const lane = Promise.withResolvers() + const occupied = host.collaboratorsForTests().serialize(SESSION, () => lane.promise) + + writesDone.resolve() + await settled() + expect((await receipt(id)).written).toBe(false) + + lane.resolve() + await occupied + await settledReceipt(id) + }) +}) + describe('a review reply cut off by a restart', () => { /** Accepted, with Orca dying while the writes run: no receipt reaches the chat. */ async function acceptedThenCrashed(): Promise { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts index bfe44ef87a97..9b6e1e8c9ab5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts @@ -119,10 +119,12 @@ export class StructuredAgentSessionReviewReplies { } } -/** The host's review replies: writes through its runtime, receipts at the chat's current fence. */ +/** The host's review replies: writes through its runtime; each receipt in the chat's own lane, at + * the fence it holds then, so no start or close moves the fence under the append. */ export function createStructuredAgentSessionHostReviewReplies(host: { deps: () => Pick journal: (sessionId: string) => AgentSessionJournal | undefined + serialize: (sessionId: string, task: () => Promise) => Promise }): { observe: (sessionId: string) => void } { const log = (message: string, error: unknown): void => host.deps().logger.warn(message, { scope: 'review-reply', error }) @@ -134,14 +136,22 @@ export function createStructuredAgentSessionHostReviewReplies(host: { } return runStructuredAgentSessionReviewReply(runtime, spec, { ...options, log }) }, - writeReceipt: async (sessionId, clientMessageId, failure) => { - const journal = host.journal(sessionId) - if (!journal) { - throw new Error(`chat ${sessionId} is closed`) - } - const fence = structuredAgentSessionConversationFence(host.deps().store, sessionId) - await writeStructuredAgentSessionReviewReplyReceipt(journal, fence, clientMessageId, failure) - }, + // Queued behind the lane's work, never awaited from inside it: the run was started off a + // publish, so a task in the lane never waits on this. + writeReceipt: (sessionId, clientMessageId, failure) => + host.serialize(sessionId, async () => { + const journal = host.journal(sessionId) + if (!journal) { + throw new Error(`chat ${sessionId} is closed`) + } + const fence = structuredAgentSessionConversationFence(host.deps().store, sessionId) + await writeStructuredAgentSessionReviewReplyReceipt( + journal, + fence, + clientMessageId, + failure + ) + }), log }) return { From 34fe8e7de53a2f0e31be55d90bbc1857e0aa59c1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:15:40 -0700 Subject: [PATCH 62/84] fix(native-chat): a review reply re-run after a restart skips only replies this account posted Before posting again, the host now matches the PR's comments on thread, text, time since the agent took the message, and author: the login the repo's GitHub writes run as (its bound gh account, else the signed-in gh user), looked up once per repo for the host's life. Where that login can't be told (SSH, WSL, nobody signed in, a failed lookup) it matches on the rest alone and logs that. --- ...tured-agent-session-review-replies.test.ts | 54 +++++++++++++++++++ ...structured-agent-session-review-replies.ts | 34 +++++++++++- ...tured-agent-session-review-reply-runner.ts | 37 ++++++++++--- .../runtime/orca-runtime-get-worktree-ps.ts | 3 +- .../runtime-github-review-query-commands.ts | 22 +++++++- ...runtime-github-review-viewer-login.test.ts | 53 ++++++++++++++++++ 6 files changed, 192 insertions(+), 11 deletions(-) create mode 100644 src/main/runtime/runtime-github-review-viewer-login.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts index da3286a88395..e5e436a22899 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts @@ -81,6 +81,9 @@ function reviewRuntime() { ), getRepoPRComments: vi.fn( async () => onPRComments + ), + getRepoViewerLogin: vi.fn( + async () => 'me' ) } } @@ -454,6 +457,57 @@ describe('a review reply cut off by a restart', () => { expect(review.resolveRepoReviewThread).toHaveBeenCalledOnce() }) + it('replies where only another account posted the same words since', async () => { + const id = await acceptedThenCrashed() + onPRComments = [ + postedComment(FIXING, { author: 'teammate', threadId: 'thread-7', path: 'src/a.ts' }) + ] + + await host.journalSnapshot(SESSION) + + await settledReceipt(id) + expect(review.getRepoViewerLogin).toHaveBeenCalledWith('id:repo-1') + expect(review.addRepoPRReviewCommentReply).toHaveBeenCalledOnce() + }) + + it("matches the words alone when this repo's account can't be told", async () => { + review.getRepoViewerLogin.mockRejectedValue(new Error('gh is not signed in')) + const id = await acceptedThenCrashed() + onPRComments = [ + postedComment(FIXING, { author: 'teammate', threadId: 'thread-7', path: 'src/a.ts' }) + ] + + await host.journalSnapshot(SESSION) + + await settledReceipt(id) + expect(review.addRepoPRReviewCommentReply).not.toHaveBeenCalled() + expect(review.addRepoIssueComment).toHaveBeenCalledOnce() + }) + + it('asks which account writes for a repo once for the host', async () => { + review.addRepoIssueComment.mockImplementation(() => new Promise(() => {})) + const first = await launchPrompt() + await handedOver(first) + await accept(first) + const second = await launchPrompt() + await handedOver(second) + await host.settleLateDispatch({ + sessionId: SESSION, + clientMessageId: second, + providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-2', ordinal: 1 } + }) + await eventually(() => expect(review.addRepoIssueComment).toHaveBeenCalledTimes(2)) + await host.flushAllStreamedEvents() + startHost() + review.addRepoIssueComment.mockResolvedValue({ ok: true, comment: postedComment('summary') }) + + await host.journalSnapshot(SESSION) + + await settledReceipt(first) + await settledReceipt(second) + expect(review.getRepoViewerLogin).toHaveBeenCalledOnce() + }) + it('posts nothing once the window since the agent took it has passed', async () => { await acceptedThenCrashed() // The journal stamps its rows, and reads the window, on the wall clock. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts index 9b6e1e8c9ab5..c62d243b2e30 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts @@ -11,7 +11,10 @@ import type { AgentJournalItemIdentity } from '../../../shared/agent-session-jou import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import { runStructuredAgentSessionReviewReply } from './structured-agent-session-review-reply-runner' +import { + runStructuredAgentSessionReviewReply, + type StructuredAgentSessionReviewRuntime +} from './structured-agent-session-review-reply-runner' /** A message accepted longer ago than this is never re-derived: a crash's leftover must not post * days later on some reopen. Measured from acceptance, so a late Retry still posts. */ @@ -128,13 +131,40 @@ export function createStructuredAgentSessionHostReviewReplies(host: { }): { observe: (sessionId: string) => void } { const log = (message: string, error: unknown): void => host.deps().logger.warn(message, { scope: 'review-reply', error }) + // Asked once per repo for the host's life; a failed lookup is asked again next time. + const logins = new Map>() + const viewerLogin = ( + runtime: StructuredAgentSessionReviewRuntime, + repo: string + ): Promise => { + const asked = + logins.get(repo) ?? + runtime.getRepoViewerLogin(repo).then((login) => { + if (login === null) { + throw new Error('no GitHub login for this repo') + } + return login + }) + logins.set(repo, asked) + return asked.catch((error: unknown) => { + if (logins.get(repo) === asked) { + logins.delete(repo) + log('review reply: could not tell which GitHub account writes for this repo', error) + } + return null + }) + } const replies = new StructuredAgentSessionReviewReplies({ run: async (spec, options) => { const runtime = host.deps().reviewRuntime if (!runtime) { throw new Error('This host cannot write to the review.') } - return runStructuredAgentSessionReviewReply(runtime, spec, { ...options, log }) + return runStructuredAgentSessionReviewReply(runtime, spec, { + ...options, + viewerLogin: (repo) => viewerLogin(runtime, repo), + log + }) }, // Queued behind the lane's work, never awaited from inside it: the run was started off a // publish, so a task in the lane never waits on this. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-review-reply-runner.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-review-reply-runner.ts index 6b0389121b3a..d720fef16456 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-review-reply-runner.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-review-reply-runner.ts @@ -1,6 +1,7 @@ // The review writes a message's review reply asks for, through the runtime's repo-scoped GitHub and // GitLab methods: the ones the review RPCs call. Resolves are idempotent; a reply is not, so a run -// that may repeat one already posted (`reread`) first reads the PR and skips what is there. +// that may repeat one already posted (`reread`) first reads the PR and skips what this account +// already posted there. import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' import type { PRComment } from '../../../shared/github/comment-types' @@ -13,7 +14,10 @@ export type StructuredAgentSessionReviewRuntime = Pick< | 'addRepoPRReviewCommentReply' | 'addRepoIssueComment' | 'getRepoPRComments' -> +> & { + /** The GitHub login the repo's writes run as; null when it cannot be told. */ + getRepoViewerLogin: (repoSelector: string) => Promise +} /** The checks panel's ceiling: the shared GitHub client keeps four calls in flight. */ const REVIEW_REPLY_CONCURRENCY = 4 @@ -29,6 +33,8 @@ export async function runStructuredAgentSessionReviewReply( acceptedAt: number /** A run that may follow an earlier one cut off before its receipt. */ reread: boolean + /** Who the repo's writes post as, asked only for a reread; null matches the text alone. */ + viewerLogin: (repoSelector: string) => Promise log: (message: string, error?: unknown) => void } ): Promise { @@ -53,7 +59,12 @@ async function gitHubWrites( runtime: StructuredAgentSessionReviewRuntime, repo: string, spec: Extract, - options: { acceptedAt: number; reread: boolean; log: (message: string, error?: unknown) => void } + options: { + acceptedAt: number + reread: boolean + viewerLogin: (repoSelector: string) => Promise + log: (message: string, error?: unknown) => void + } ): Promise { const posted = options.reread ? await postedSince(runtime, repo, spec, options) : [] const alreadyPosted = (body: string, at: { threadId?: string; path?: string } | null) => @@ -105,13 +116,18 @@ async function gitHubWrites( ] } -/** The PR's comments written since the agent took the message. A failed read answers none, which - * can post one reply twice: reported, never blocking the rest. */ +/** The PR's comments this account wrote since the agent took the message. A failed read answers + * none, which can post one reply twice; an unknown account matches any author. Both are reported, + * never blocking the rest. */ async function postedSince( runtime: StructuredAgentSessionReviewRuntime, repo: string, spec: Extract, - options: { acceptedAt: number; log: (message: string, error?: unknown) => void } + options: { + acceptedAt: number + viewerLogin: (repoSelector: string) => Promise + log: (message: string, error?: unknown) => void + } ): Promise { if (spec.replies.length === 0 && !spec.conversationReply) { return [] @@ -120,9 +136,16 @@ async function postedSince( const comments = await runtime.getRepoPRComments(repo, spec.prNumber, spec.prRepo ?? null, { noCache: true }) + const login = await options.viewerLogin(repo) + if (login === null) { + options.log('review reply: the account is unknown, so a reply is matched by its text', null) + } // GitHub stamps whole seconds. const since = Math.floor(options.acceptedAt / 1000) * 1000 - return comments.filter((comment) => Date.parse(comment.createdAt) >= since) + return comments.filter( + (comment) => + Date.parse(comment.createdAt) >= since && (login === null || comment.author === login) + ) } catch (error) { options.log('review reply: could not read the PR before replying; a reply may repeat', error) return [] diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index 4f4b1a25f237..f50eeec22d1b 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -191,7 +191,8 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis addRepoPRReviewCommentReply: (...args) => this.gitHubIssueComments.addRepoPRReviewCommentReply(...args), addRepoIssueComment: (...args) => this.gitHubIssueComments.addRepoIssueComment(...args), - getRepoPRComments: (...args) => this.gitHubReviewQueries.getRepoPRComments(...args) + getRepoPRComments: (...args) => this.gitHubReviewQueries.getRepoPRComments(...args), + getRepoViewerLogin: (repo) => this.gitHubReviewQueries.getRepoViewerLogin(repo) } }) } diff --git a/src/main/runtime/runtime-github-review-query-commands.ts b/src/main/runtime/runtime-github-review-query-commands.ts index e9d248648288..9007eefc63a2 100644 --- a/src/main/runtime/runtime-github-review-query-commands.ts +++ b/src/main/runtime/runtime-github-review-query-commands.ts @@ -1,7 +1,13 @@ import type { GitHubOwnerRepo, GitHubPRFile } from '../../shared/github/pull-request-types' import type { Repo } from '../../shared/repo-types' import type { LocalProjectGhExecOptions } from '../project-runtime-git-options' -import { getIssue, getPRCheckDetails, getPRChecks, getPRComments } from '../github/client' +import { + getAuthenticatedViewer, + getIssue, + getPRCheckDetails, + getPRChecks, + getPRComments +} from '../github/client' import { getPRFileContents } from '../github/work-item-details' type LocalGitArgs = [] | [LocalProjectGhExecOptions] @@ -14,6 +20,20 @@ type RuntimeGitHubReviewQueryCommandsDeps = { export class RuntimeGitHubReviewQueryCommands { constructor(private readonly deps: RuntimeGitHubReviewQueryCommandsDeps) {} + /** The login this repo's GitHub writes run as: its bound account, else the signed-in gh user. + * Null where that user is not this machine's (SSH, WSL) or no one is signed in. */ + async getRepoViewerLogin(repoSelector: string): Promise { + const repo = await this.deps.resolveRepo(repoSelector) + const [options] = this.deps.getLocalGitArgs(repo) + if (options?.ghAccount) { + return options.ghAccount.user + } + if (repo.connectionId || options?.wslDistro) { + return null + } + return (await getAuthenticatedViewer())?.login ?? null + } + async getRepoIssue( repoSelector: string, number: number diff --git a/src/main/runtime/runtime-github-review-viewer-login.test.ts b/src/main/runtime/runtime-github-review-viewer-login.test.ts new file mode 100644 index 000000000000..f85f5594cd3f --- /dev/null +++ b/src/main/runtime/runtime-github-review-viewer-login.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const viewer = vi.hoisted(() => vi.fn()) +vi.mock('../github/client', async (importOriginal) => ({ + ...(await importOriginal()), + getAuthenticatedViewer: viewer +})) + +import type { Repo } from '../../shared/repo-types' +import type { LocalProjectGhExecOptions } from '../project-runtime-git-options' +import { RuntimeGitHubReviewQueryCommands } from './runtime-github-review-query-commands' + +function commands(repo: Partial, options?: LocalProjectGhExecOptions) { + return new RuntimeGitHubReviewQueryCommands({ + resolveRepo: async (): Promise => ({ + id: 'repo-1', + path: '/repos/app', + displayName: 'app', + badgeColor: 'blue', + addedAt: 1, + ...repo + }), + getLocalGitArgs: () => (options ? [options] : []) + }) +} + +describe("the GitHub login a repo's writes run as", () => { + beforeEach(() => { + viewer.mockReset() + viewer.mockResolvedValue({ login: 'signed-in', email: null }) + }) + + it("is the repo's bound account", async () => { + const login = await commands( + {}, + { + ghAccount: { host: 'github.com', user: 'bound' } + } + ).getRepoViewerLogin('id:repo-1') + expect(login).toBe('bound') + expect(viewer).not.toHaveBeenCalled() + }) + + it('is the signed-in gh user otherwise', async () => { + await expect(commands({}).getRepoViewerLogin('id:repo-1')).resolves.toBe('signed-in') + }) + + it('is unknown where the signed-in user is not the one the writes run as', async () => { + await expect(commands({ connectionId: 'ssh-1' }).getRepoViewerLogin('x')).resolves.toBeNull() + await expect(commands({}, { wslDistro: 'Ubuntu' }).getRepoViewerLogin('x')).resolves.toBeNull() + expect(viewer).not.toHaveBeenCalled() + }) +}) From f36c641e6249e401615a1d48dd2874f6ba2c8d3e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:21:58 -0700 Subject: [PATCH 63/84] fix(native-chat): a review reply rides a launch prompt only to a host that advertises running it With #24340's route, a structured chat now starts on whichever host owns the workspace, a paired server included, and an older server's strict send params would refuse the whole launch prompt. The host now advertises agent-session.review-reply.v1, and the checks panel plans the reply only for a chat whose host does (this client's own always does). A chat on an older paired server gets the prompt without it, and the panel writes the replies itself once the host has the message, as it did before this PR. --- ...tured-agent-session-review-replies.test.ts | 10 +++++ .../SourceControlAgentActionDialog.tsx | 6 +-- .../comment-resolution-review-reply.test.tsx | 22 ++++++++++ .../use-checks-panel-ai-acknowledgement.tsx | 4 +- .../runSourceControlAgentActionStart.ts | 19 +++++---- .../useSourceControlAgentActionStart.ts | 2 +- ...agent-session-review-reply-support.test.ts | 42 +++++++++++++++++++ ...ured-agent-session-review-reply-support.ts | 24 +++++++++++ ...t-session-message-delivery-capabilities.ts | 7 +++- 9 files changed, 122 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/lib/structured-agent-session-review-reply-support.test.ts create mode 100644 src/renderer/src/lib/structured-agent-session-review-reply-support.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts index e5e436a22899..29a9867db3bb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts @@ -11,6 +11,10 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journ import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' import type { PRComment } from '../../../shared/github/comment-types' +import { + AGENT_SESSION_REVIEW_REPLY_RUNTIME_CAPABILITY, + RUNTIME_CAPABILITIES +} from '../../../shared/protocol-version' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { @@ -562,3 +566,9 @@ describe('what a send may carry', () => { }) }) }) + +describe('who learns a host runs review replies', () => { + it('is every client: the host says so, so a client attaches one only where it runs', () => { + expect(RUNTIME_CAPABILITIES).toContain(AGENT_SESSION_REVIEW_REPLY_RUNTIME_CAPABILITY) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx index 87dbea962f73..6c97540fc0e5 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx @@ -47,9 +47,9 @@ export type SourceControlAgentActionDialogProps = { onLaunchAccepted?: () => void /** Fires when an accepted launch later failed to deliver its prompt. */ onLaunchAborted?: () => void - /** `structuredChat`: the launch opened a structured chat, whose host writes `reviewReply` once - * the agent takes the message; the caller writes nothing to the review itself. */ - onLaunched?: (launch: { structuredChat: boolean }) => void + /** `reviewReplyCarried`: a structured chat's message carries `reviewReply`, which its host writes + * once the agent takes the message; the caller writes nothing to the review itself. */ + onLaunched?: (launch: { reviewReplyCarried: boolean }) => void /** Planned at start, for a structured chat's launch prompt to carry. */ reviewReply?: () => AgentSessionReviewReply | undefined startLabel?: string diff --git a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx index f2f180592704..0c0956837b35 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx @@ -8,6 +8,7 @@ import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ + hostRunsReviewReplies: vi.fn(async () => true), launchAgentInNewTab: vi.fn(), toastError: vi.fn(), toastSuccess: vi.fn() @@ -15,6 +16,9 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab: mocks.launchAgentInNewTab })) vi.mock('@/lib/focus-terminal-tab-surface', () => ({ focusTerminalTabSurface: vi.fn() })) +vi.mock('@/lib/structured-agent-session-review-reply-support', () => ({ + structuredChatHostRunsReviewReplies: mocks.hostRunsReviewReplies +})) vi.mock('sonner', () => ({ toast: { error: mocks.toastError, success: mocks.toastSuccess } })) import type { PRComment } from '../../../../../shared/github/comment-types' @@ -164,6 +168,24 @@ describe('Resolve comments with AI', () => { expect(model.pendingCommentResolutionRef.current).toBeNull() }) + it('writes once from the panel when the chat runs on a host that cannot carry the reply', async () => { + mocks.hostRunsReviewReplies.mockResolvedValueOnce(false) + const { model, hook } = acknowledgement() + mocks.launchAgentInNewTab.mockReturnValue({ + surface: { kind: 'host-published' }, + promptDeliveryResult: DELIVERED, + structuredSettlement: Promise.resolve({ kind: 'launched' }) + }) + + await expect(resolveCommentsWithAi(hook)).resolves.toBe(true) + + expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith( + expect.not.objectContaining({ reviewReply: expect.anything() }) + ) + await vi.waitFor(() => expect(model.resolveReviewThread).toHaveBeenCalledOnce()) + expect(model.addPRConversationComment).toHaveBeenCalledOnce() + }) + it('writes once from the panel when a terminal agent took the paste', async () => { const { model, hook } = acknowledgement() mocks.launchAgentInNewTab.mockReturnValue({ diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx index b0ab024db63f..11828ca9aac2 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx @@ -313,7 +313,7 @@ export function useChecksPanelAiAcknowledgement(model: ChecksPanelAiAcknowledgem /** Prompt reached the agent: only now may Orca write to the host. A structured chat's message * carries the writes for its host instead, so here they are only handed off. */ const consumeClaimedCommentResolutionAfterDelivery = useCallback( - (launch?: { structuredChat: boolean }): void => { + (launch?: { reviewReplyCarried: boolean }): void => { const resolution = claimedCommentResolutionRef.current ?? takePendingPRCommentAiAck() ?? @@ -325,7 +325,7 @@ export function useChecksPanelAiAcknowledgement(model: ChecksPanelAiAcknowledgem setCommentResolutionAckBusyNow(false) return } - if (launch?.structuredChat) { + if (launch?.reviewReplyCarried) { clearSentCommentSelection(resolution.reviewContextKey) setCommentResolutionAckBusyNow(false) return diff --git a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts index f273076b3c9f..20ffc1e91452 100644 --- a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts @@ -1,6 +1,7 @@ import type { AgentSessionReviewReply } from '../../../../shared/agent-session-review-reply' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' +import { structuredChatHostRunsReviewReplies } from '@/lib/structured-agent-session-review-reply-support' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { Repo } from '../../../../shared/repo-types' import type { TuiAgent } from '../../../../shared/tui-agent' @@ -52,9 +53,9 @@ type RunSourceControlAgentActionStartArgs = { onLaunchAccepted?: () => void /** Fires when a launch that already reported onLaunchAccepted failed to deliver its prompt. */ onLaunchAborted?: () => void - /** `structuredChat`: the launch opened a structured chat, whose message carries any review - * reply, so the caller writes nothing to the review itself. */ - onLaunched?: (launch: { structuredChat: boolean }) => void + /** `reviewReplyCarried`: a structured chat's message carries the review reply, which its host + * writes once the agent takes it, so the caller writes nothing to the review itself. */ + onLaunched?: (launch: { reviewReplyCarried: boolean }) => void /** Planned at start: what a structured chat's host writes on the review once its agent takes * the prompt. A terminal agent has no message to carry it. */ reviewReply?: () => AgentSessionReviewReply | undefined @@ -86,7 +87,7 @@ export async function runSourceControlAgentActionStart({ onClose }: RunSourceControlAgentActionStartArgs): Promise { let launched = false - let structuredChat = false + let reviewReplyCarried = false let launchFailureNotified = false let launchAcceptedNotified = false // Why: `undefined` is what makes the launch fall back to the global Agents arguments; @@ -109,7 +110,11 @@ export async function runSourceControlAgentActionStart({ notifyLaunchAccepted() } } else if (worktreeId) { - const plannedReviewReply = reviewReply?.() + // Only a chat whose host runs it carries one; otherwise the caller writes after delivery. + const plannedReviewReply = + reviewReply && (await structuredChatHostRunsReviewReplies(worktreeId)) + ? reviewReply() + : undefined const result = launchAgentInNewTab({ agent: selectedAgent, worktreeId, @@ -122,7 +127,7 @@ export async function runSourceControlAgentActionStart({ ...(plannedReviewReply ? { reviewReply: plannedReviewReply } : {}) }) launched = Boolean(result) - structuredChat = Boolean(result?.structuredSettlement) + reviewReplyCarried = Boolean(plannedReviewReply && result?.structuredSettlement) if (result?.surface.kind === 'local-terminal') { focusTerminalTabSurface(result.surface.tabId) } @@ -182,7 +187,7 @@ export async function runSourceControlAgentActionStart({ console.error('onSaveAgentDefault failed', error) } } - onLaunched?.({ structuredChat }) + onLaunched?.({ reviewReplyCarried }) onClose() return true } diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts index 2ba4009703a1..c23dfcf45267 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts @@ -51,7 +51,7 @@ type UseSourceControlAgentActionStartArgs = { ) => void | Promise onLaunchAccepted?: () => void onLaunchAborted?: () => void - onLaunched?: (launch: { structuredChat: boolean }) => void + onLaunched?: (launch: { reviewReplyCarried: boolean }) => void reviewReply?: () => AgentSessionReviewReply | undefined onClose: () => void } diff --git a/src/renderer/src/lib/structured-agent-session-review-reply-support.test.ts b/src/renderer/src/lib/structured-agent-session-review-reply-support.test.ts new file mode 100644 index 000000000000..6a32de623af2 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-review-reply-support.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + owner: vi.fn<() => string | null>(), + target: vi.fn(), + supports: vi.fn<() => Promise>() +})) + +vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } })) +vi.mock('@/runtime/structured-agent-session-owner', () => ({ + resolveStructuredAgentSessionOwner: mocks.owner, + structuredAgentSessionTargetForHost: mocks.target +})) +vi.mock('@/runtime/runtime-rpc-client', () => ({ + runtimeEnvironmentSupportsCapability: mocks.supports +})) + +import { structuredChatHostRunsReviewReplies } from './structured-agent-session-review-reply-support' + +describe('whether a structured chat here can carry a review reply', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('can on this client own host, asking no one', async () => { + mocks.target.mockReturnValue({ kind: 'local' }) + await expect(structuredChatHostRunsReviewReplies('wt-1')).resolves.toBe(true) + expect(mocks.supports).not.toHaveBeenCalled() + }) + + it('can on a paired host only once it says so', async () => { + mocks.target.mockReturnValue({ kind: 'environment', environmentId: 'env-1' }) + mocks.supports.mockResolvedValueOnce(true) + await expect(structuredChatHostRunsReviewReplies('wt-1')).resolves.toBe(true) + expect(mocks.supports).toHaveBeenCalledWith('env-1', 'agent-session.review-reply.v1') + + mocks.supports.mockResolvedValueOnce(false) + await expect(structuredChatHostRunsReviewReplies('wt-1')).resolves.toBe(false) + mocks.supports.mockRejectedValueOnce(new Error('unreachable')) + await expect(structuredChatHostRunsReviewReplies('wt-1')).resolves.toBe(false) + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-review-reply-support.ts b/src/renderer/src/lib/structured-agent-session-review-reply-support.ts new file mode 100644 index 000000000000..3a290f1ea674 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-review-reply-support.ts @@ -0,0 +1,24 @@ +// Whether a structured chat started in this workspace can carry a review reply on its launch +// prompt. Its host runs the reply, and an older paired server refuses a send that names one. + +import { AGENT_SESSION_REVIEW_REPLY_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' +import { useAppStore } from '@/store' +import { + resolveStructuredAgentSessionOwner, + structuredAgentSessionTargetForHost +} from '@/runtime/structured-agent-session-owner' +import { runtimeEnvironmentSupportsCapability } from '@/runtime/runtime-rpc-client' + +/** This client's own host always can; a paired one only once it says so. */ +export async function structuredChatHostRunsReviewReplies(worktreeId: string): Promise { + const target = structuredAgentSessionTargetForHost( + resolveStructuredAgentSessionOwner(useAppStore.getState(), worktreeId) + ) + if (target?.kind !== 'environment') { + return true + } + return runtimeEnvironmentSupportsCapability( + target.environmentId, + AGENT_SESSION_REVIEW_REPLY_RUNTIME_CAPABILITY + ).catch(() => false) +} diff --git a/src/shared/agent-session-message-delivery-capabilities.ts b/src/shared/agent-session-message-delivery-capabilities.ts index 36f179bd009c..c05b372f0c4c 100644 --- a/src/shared/agent-session-message-delivery-capabilities.ts +++ b/src/shared/agent-session-message-delivery-capabilities.ts @@ -16,6 +16,10 @@ export const AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY = // as a new message. export const AGENT_SESSION_RETRY_MESSAGE_RUNTIME_CAPABILITY = 'agent-session.retry-message.v1' as const +// Why: `agentSession.send` takes a `reviewReply` the host runs once the agent takes the message; an +// older host's strict params refuse the whole send, so a client attaches one only where advertised. +export const AGENT_SESSION_REVIEW_REPLY_RUNTIME_CAPABILITY = + 'agent-session.review-reply.v1' as const // How a send is answered. The host and the Electron client both advertise these. export const AGENT_SESSION_SEND_ANSWER_RUNTIME_CAPABILITIES = [ @@ -27,5 +31,6 @@ export const AGENT_SESSION_SEND_ANSWER_RUNTIME_CAPABILITIES = [ // turn starts, so a client may gate on either. export const AGENT_SESSION_MESSAGE_DELIVERY_RUNTIME_CAPABILITIES = [ ...AGENT_SESSION_SEND_ANSWER_RUNTIME_CAPABILITIES, - AGENT_SESSION_RETRY_MESSAGE_RUNTIME_CAPABILITY + AGENT_SESSION_RETRY_MESSAGE_RUNTIME_CAPABILITY, + AGENT_SESSION_REVIEW_REPLY_RUNTIME_CAPABILITY ] as const From fe7636af684aedaeaa449540cd40c18ebe801009 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:27:03 -0700 Subject: [PATCH 64/84] fix(native-chat): a review reply re-run reads the reply threads newest first, through a read that throws The restart dedupe read the panel's PR comments: one oldest-first page per source, every failure swallowed as an empty list. A busy PR hid Orca's own replies, and a failed read posted again with nothing logged. It now reads exactly the reply threads (`node(id:)`, `comments(last: 100)`), the conversation comments since a minute before the agent took the message (host and GitHub clocks can disagree), and `viewer { login }` in the same GraphQL call, which replaces the separate account lookup. A failed read is logged and the replies are posted (at worst once more); an unnamed account matches by text alone. --- .../client/fetch/review-reply-posts.test.ts | 86 +++++++++++++ .../github/client/fetch/review-reply-posts.ts | 117 ++++++++++++++++++ ...tured-agent-session-review-replies.test.ts | 92 ++++++++------ ...structured-agent-session-review-replies.ts | 34 +---- ...tured-agent-session-review-reply-runner.ts | 94 +++++++------- .../runtime/orca-runtime-get-worktree-ps.ts | 4 +- .../runtime-github-review-query-commands.ts | 33 +++-- ...runtime-github-review-viewer-login.test.ts | 53 -------- 8 files changed, 324 insertions(+), 189 deletions(-) create mode 100644 src/main/github/client/fetch/review-reply-posts.test.ts create mode 100644 src/main/github/client/fetch/review-reply-posts.ts delete mode 100644 src/main/runtime/runtime-github-review-viewer-login.test.ts diff --git a/src/main/github/client/fetch/review-reply-posts.test.ts b/src/main/github/client/fetch/review-reply-posts.test.ts new file mode 100644 index 000000000000..660cceb1b4aa --- /dev/null +++ b/src/main/github/client/fetch/review-reply-posts.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + gh: vi.fn<(args: string[]) => Promise<{ stdout: string; stderr: string }>>() +})) + +vi.mock('../../gh-utils', () => ({ + ghExecFileAsync: mocks.gh, + acquire: async () => {}, + release: () => {} +})) +vi.mock('../../github-api-repository', () => ({ + resolveGitHubRepoExecution: async () => ({ + ownerRepo: { owner: 'acme', repo: 'app' }, + ghOptions: {} + }) +})) + +import { getReviewReplyPosts } from './review-reply-posts' + +const SINCE = '2026-10-03T10:00:00.000Z' + +function answer(graphql: unknown, rest: unknown = []) { + mocks.gh.mockImplementation(async (args) => ({ + stdout: JSON.stringify(args[1] === 'graphql' ? graphql : rest), + stderr: '' + })) +} + +function read(threadIds: string[] = ['PRRT_a', 'PRRT_b']) { + return getReviewReplyPosts('/repos/app', { prNumber: 42, threadIds, since: SINCE }) +} + +describe('what a review reply may already have posted', () => { + beforeEach(() => { + mocks.gh.mockReset() + }) + + it("reads each reply's thread newest first, the conversation since, and the account", async () => { + answer( + { + data: { + viewer: { login: 'me' }, + t0: { + comments: { nodes: [{ author: { login: 'me' }, body: 'Fixing', createdAt: SINCE }] } + }, + t1: { comments: { nodes: [] } } + } + }, + [{ user: { login: 'me' }, body: 'Fixing all', created_at: SINCE }] + ) + + await expect(read()).resolves.toEqual({ + viewerLogin: 'me', + threads: { + PRRT_a: [{ author: 'me', body: 'Fixing', createdAt: SINCE }], + PRRT_b: [] + }, + conversation: [{ author: 'me', body: 'Fixing all', createdAt: SINCE }] + }) + const [graphqlArgs, restArgs] = mocks.gh.mock.calls.map(([args]) => args) + expect(graphqlArgs?.join(' ')).toContain('t0: node(id: "PRRT_a")') + expect(graphqlArgs?.join(' ')).toContain('comments(last: 100)') + expect(restArgs?.[1]).toBe( + `repos/acme/app/issues/42/comments?since=${encodeURIComponent(SINCE)}&per_page=100` + ) + }) + + it('never puts something that is not a node id into the query', async () => { + answer({ data: { viewer: { login: 'me' } } }) + + await expect(read(['"){ evil }'])).resolves.toMatchObject({ threads: {} }) + expect(mocks.gh.mock.calls[0]?.[0].join(' ')).not.toContain('evil') + }) + + it('throws when GitHub refuses either read, instead of reading as nothing posted', async () => { + answer({ data: null, errors: [{ message: 'Could not resolve to a node' }] }) + await expect(read()).rejects.toThrow('GitHub refused the thread read') + + mocks.gh.mockRejectedValue(new Error('HTTP 502')) + await expect(read()).rejects.toThrow('HTTP 502') + + answer({ data: { viewer: { login: 'me' } } }, { message: 'Not Found' }) + await expect(read()).rejects.toThrow('conversation read') + }) +}) diff --git a/src/main/github/client/fetch/review-reply-posts.ts b/src/main/github/client/fetch/review-reply-posts.ts new file mode 100644 index 000000000000..2e8c6b34e324 --- /dev/null +++ b/src/main/github/client/fetch/review-reply-posts.ts @@ -0,0 +1,117 @@ +// What a review reply may already have posted on a PR: the newest comments on each reply's thread, +// the conversation comments since a moment, and who the repo's gh account is. Unlike the panel's +// comment read it throws on any failure, so a caller can tell "nothing posted" from "not read". + +import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils' +import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository' + +export type ReviewReplyPost = { author: string; body: string; createdAt: string } + +export type ReviewReplyPosts = { + /** The account this repo's gh calls run as; null when GitHub did not say. */ + viewerLogin: string | null + /** The newest 100 comments on each asked thread, by thread id. */ + threads: Record + /** PR conversation comments updated since `since`, up to 100. */ + conversation: ReviewReplyPost[] +} + +// GitHub node ids are base64url-like; anything else is not one, and is never put into the query. +const NODE_ID = /^[A-Za-z0-9_=+/-]{1,512}$/ + +function threadsQuery(threadIds: readonly string[]): string { + const threads = threadIds.map( + (id, index) => + `t${index}: node(id: ${JSON.stringify(id)}) { ... on PullRequestReviewThread { comments(last: 100) { nodes { author { login } body createdAt } } } }` + ) + return `query { viewer { login } ${threads.join(' ')} }` +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** One comment as either API returns it; null for anything that is not one. */ +function readPost( + value: unknown, + authorKey: 'author' | 'user', + createdKey: string +): ReviewReplyPost | null { + if (!isRecord(value) || typeof value[createdKey] !== 'string') { + return null + } + const author = value[authorKey] + return { + author: isRecord(author) && typeof author.login === 'string' ? author.login : 'ghost', + body: typeof value.body === 'string' ? value.body : '', + createdAt: String(value[createdKey]) + } +} + +function readPosts(values: unknown, authorKey: 'author' | 'user', createdKey: string) { + return (Array.isArray(values) ? values : []).flatMap((value) => { + const read = readPost(value, authorKey, createdKey) + return read ? [read] : [] + }) +} + +export async function getReviewReplyPosts( + repoPath: string, + args: { + prNumber: number + prRepo?: GitHubApiRepository | null + threadIds: readonly string[] + /** ISO time; the conversation read starts here. */ + since: string + }, + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): Promise { + const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution( + repoPath, + args.prRepo, + connectionId, + localGitOptions + ) + if (!ownerRepo) { + throw new Error('Not a GitHub repository.') + } + const threadIds = args.threadIds.filter((id) => NODE_ID.test(id)) + await acquire() + try { + const [graphql, rest] = await Promise.all([ + ghExecFileAsync(['api', 'graphql', '-f', `query=${threadsQuery(threadIds)}`], ghOptions), + ghExecFileAsync( + [ + 'api', + `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${args.prNumber}/comments?since=${encodeURIComponent(args.since)}&per_page=100` + ], + ghOptions + ) + ]) + const parsed: unknown = JSON.parse(graphql.stdout) + const data = isRecord(parsed) ? parsed.data : undefined + const errors = isRecord(parsed) && Array.isArray(parsed.errors) ? parsed.errors : [] + if (!isRecord(data) || errors.length > 0) { + throw new Error(`GitHub refused the thread read: ${JSON.stringify(errors)}`) + } + const threads: Record = {} + threadIds.forEach((id, index) => { + const node = data[`t${index}`] + const comments = isRecord(node) && isRecord(node.comments) ? node.comments.nodes : [] + threads[id] = readPosts(comments, 'author', 'createdAt') + }) + const conversation: unknown = JSON.parse(rest.stdout) + if (!Array.isArray(conversation)) { + throw new Error('GitHub answered the conversation read with something else.') + } + const viewer = data.viewer + return { + viewerLogin: isRecord(viewer) && typeof viewer.login === 'string' ? viewer.login : null, + threads, + conversation: readPosts(conversation, 'user', 'created_at') + } + } finally { + release() + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts index 29a9867db3bb..14bf3004f25c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts @@ -11,6 +11,7 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journ import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' import type { PRComment } from '../../../shared/github/comment-types' +import type { ReviewReplyPosts } from '../../github/client/fetch/review-reply-posts' import { AGENT_SESSION_REVIEW_REPLY_RUNTIME_CAPABILITY, RUNTIME_CAPABILITIES @@ -22,7 +23,6 @@ import { type StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -38,7 +38,10 @@ import { REVIEW_REPLY_WINDOW_MS, reviewReplyReceiptIdentity } from './structured-agent-session-review-replies' -import type { StructuredAgentSessionReviewRuntime } from './structured-agent-session-review-reply-runner' +import { + REVIEW_REPLY_CLOCK_MARGIN_MS, + type StructuredAgentSessionReviewRuntime +} from './structured-agent-session-review-reply-runner' const CALLER = { callerKey: 'client-1' } const FIXING = 'Fixing. Will be in the next commit' @@ -62,7 +65,8 @@ let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let beforeSpawn = vi.fn<() => Promise>() let dispatch = vi.fn() -let onPRComments: PRComment[] = [] +let warnings: string[] = [] +let onPR: ReviewReplyPosts = { viewerLogin: 'me', threads: {}, conversation: [] } let review: { [Method in keyof StructuredAgentSessionReviewRuntime]: ReturnType< typeof vi.fn @@ -83,11 +87,8 @@ function reviewRuntime() { addRepoIssueComment: vi.fn( async () => ({ ok: true as const, comment: postedComment('summary') }) ), - getRepoPRComments: vi.fn( - async () => onPRComments - ), - getRepoViewerLogin: vi.fn( - async () => 'me' + getRepoReviewReplyPosts: vi.fn( + async () => onPR ) } } @@ -106,7 +107,7 @@ function postedComment(body: string, extra: Partial = {}): PRComment function startHost(): void { host = new StructuredAgentSessionHost({ - logger: createStructuredAgentSessionLogger(), + logger: { warn: (message) => warnings.push(message), error: vi.fn() }, store, adapter: { acquire: vi.fn(async ({ fence, spawnToken }) => { @@ -148,7 +149,8 @@ function startHost(): void { beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-review-replies-')) resetHostTestOperationIds() - onPRComments = [] + onPR = { viewerLogin: 'me', threads: {}, conversation: [] } + warnings = [] review = reviewRuntime() beforeSpawn = vi.fn(async () => undefined) dispatch = vi.fn(async () => ({ state: 'admitted' as const })) @@ -309,7 +311,7 @@ describe('a launch prompt with a review reply', () => { GITHUB.prRepo ) // A live accept needs no read of the PR, and all going through shows nothing in the chat. - expect(review.getRepoPRComments).not.toHaveBeenCalled() + expect(review.getRepoReviewReplyPosts).not.toHaveBeenCalled() expect((await receipt(id)).line).toBeUndefined() // Later commits re-derive nothing. @@ -434,12 +436,15 @@ describe("a review reply's receipt", () => { describe('a review reply cut off by a restart', () => { /** Accepted, with Orca dying while the writes run: no receipt reaches the chat. */ + let acceptedAt = 0 + async function acceptedThenCrashed(): Promise { review.addRepoIssueComment.mockImplementationOnce(() => new Promise(() => {})) const id = await launchPrompt() await handedOver(id) await accept(id) await eventually(() => expect(review.addRepoIssueComment).toHaveBeenCalled()) + acceptedAt = (await submission(id))?.resolvedAt ?? 0 await host.flushAllStreamedEvents() startHost() review.resolveRepoReviewThread.mockClear() @@ -448,38 +453,53 @@ describe('a review reply cut off by a restart', () => { return id } + /** A post on thread-7 by `author`, `secondsAfter` the agent took the message. */ + function onThread(author: string, secondsAfter: number): ReviewReplyPosts['threads'] { + const at = new Date(acceptedAt + secondsAfter * 1000).toISOString() + return { 'thread-7': [{ author, body: FIXING, createdAt: at }] } + } + it('posts only what the PR does not show yet, once the chat opens again', async () => { const id = await acceptedThenCrashed() - onPRComments = [postedComment(FIXING, { threadId: 'thread-7', path: 'src/a.ts' })] + onPR = { viewerLogin: 'me', threads: onThread('me', 1), conversation: [] } await host.journalSnapshot(SESSION) await settledReceipt(id) - expect(review.getRepoPRComments).toHaveBeenCalledOnce() + expect(review.getRepoReviewReplyPosts).toHaveBeenCalledExactlyOnceWith('id:repo-1', { + prNumber: 42, + prRepo: GITHUB.prRepo, + threadIds: ['thread-7'], + since: new Date(acceptedAt - REVIEW_REPLY_CLOCK_MARGIN_MS).toISOString() + }) expect(review.addRepoPRReviewCommentReply).not.toHaveBeenCalled() expect(review.addRepoIssueComment).toHaveBeenCalledOnce() expect(review.resolveRepoReviewThread).toHaveBeenCalledOnce() }) + it("counts this account's post from a host clock running ahead of GitHub's", async () => { + const id = await acceptedThenCrashed() + onPR = { viewerLogin: 'me', threads: onThread('me', -30), conversation: [] } + + await host.journalSnapshot(SESSION) + + await settledReceipt(id) + expect(review.addRepoPRReviewCommentReply).not.toHaveBeenCalled() + }) + it('replies where only another account posted the same words since', async () => { const id = await acceptedThenCrashed() - onPRComments = [ - postedComment(FIXING, { author: 'teammate', threadId: 'thread-7', path: 'src/a.ts' }) - ] + onPR = { viewerLogin: 'me', threads: onThread('teammate', 1), conversation: [] } await host.journalSnapshot(SESSION) await settledReceipt(id) - expect(review.getRepoViewerLogin).toHaveBeenCalledWith('id:repo-1') expect(review.addRepoPRReviewCommentReply).toHaveBeenCalledOnce() }) - it("matches the words alone when this repo's account can't be told", async () => { - review.getRepoViewerLogin.mockRejectedValue(new Error('gh is not signed in')) + it("matches the words alone when GitHub doesn't say which account it is", async () => { const id = await acceptedThenCrashed() - onPRComments = [ - postedComment(FIXING, { author: 'teammate', threadId: 'thread-7', path: 'src/a.ts' }) - ] + onPR = { viewerLogin: null, threads: onThread('teammate', 1), conversation: [] } await host.journalSnapshot(SESSION) @@ -488,28 +508,18 @@ describe('a review reply cut off by a restart', () => { expect(review.addRepoIssueComment).toHaveBeenCalledOnce() }) - it('asks which account writes for a repo once for the host', async () => { - review.addRepoIssueComment.mockImplementation(() => new Promise(() => {})) - const first = await launchPrompt() - await handedOver(first) - await accept(first) - const second = await launchPrompt() - await handedOver(second) - await host.settleLateDispatch({ - sessionId: SESSION, - clientMessageId: second, - providerIdentity: { provider: 'codex', threadId: THREAD, turnId: 'turn-2', ordinal: 1 } - }) - await eventually(() => expect(review.addRepoIssueComment).toHaveBeenCalledTimes(2)) - await host.flushAllStreamedEvents() - startHost() - review.addRepoIssueComment.mockResolvedValue({ ok: true, comment: postedComment('summary') }) + it('posts everything once more when the read fails, and says so', async () => { + const id = await acceptedThenCrashed() + review.getRepoReviewReplyPosts.mockRejectedValueOnce(new Error('HTTP 502')) await host.journalSnapshot(SESSION) - await settledReceipt(first) - await settledReceipt(second) - expect(review.getRepoViewerLogin).toHaveBeenCalledOnce() + await settledReceipt(id) + expect(review.addRepoPRReviewCommentReply).toHaveBeenCalledOnce() + expect(review.addRepoIssueComment).toHaveBeenCalledOnce() + expect(warnings).toContain( + 'review reply: could not read the PR before replying; a reply may repeat' + ) }) it('posts nothing once the window since the agent took it has passed', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts index c62d243b2e30..9b6e1e8c9ab5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts @@ -11,10 +11,7 @@ import type { AgentJournalItemIdentity } from '../../../shared/agent-session-jou import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' -import { - runStructuredAgentSessionReviewReply, - type StructuredAgentSessionReviewRuntime -} from './structured-agent-session-review-reply-runner' +import { runStructuredAgentSessionReviewReply } from './structured-agent-session-review-reply-runner' /** A message accepted longer ago than this is never re-derived: a crash's leftover must not post * days later on some reopen. Measured from acceptance, so a late Retry still posts. */ @@ -131,40 +128,13 @@ export function createStructuredAgentSessionHostReviewReplies(host: { }): { observe: (sessionId: string) => void } { const log = (message: string, error: unknown): void => host.deps().logger.warn(message, { scope: 'review-reply', error }) - // Asked once per repo for the host's life; a failed lookup is asked again next time. - const logins = new Map>() - const viewerLogin = ( - runtime: StructuredAgentSessionReviewRuntime, - repo: string - ): Promise => { - const asked = - logins.get(repo) ?? - runtime.getRepoViewerLogin(repo).then((login) => { - if (login === null) { - throw new Error('no GitHub login for this repo') - } - return login - }) - logins.set(repo, asked) - return asked.catch((error: unknown) => { - if (logins.get(repo) === asked) { - logins.delete(repo) - log('review reply: could not tell which GitHub account writes for this repo', error) - } - return null - }) - } const replies = new StructuredAgentSessionReviewReplies({ run: async (spec, options) => { const runtime = host.deps().reviewRuntime if (!runtime) { throw new Error('This host cannot write to the review.') } - return runStructuredAgentSessionReviewReply(runtime, spec, { - ...options, - viewerLogin: (repo) => viewerLogin(runtime, repo), - log - }) + return runStructuredAgentSessionReviewReply(runtime, spec, { ...options, log }) }, // Queued behind the lane's work, never awaited from inside it: the run was started off a // publish, so a task in the lane never waits on this. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-review-reply-runner.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-review-reply-runner.ts index d720fef16456..9e58e66bd795 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-review-reply-runner.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-review-reply-runner.ts @@ -1,11 +1,15 @@ // The review writes a message's review reply asks for, through the runtime's repo-scoped GitHub and // GitLab methods: the ones the review RPCs call. Resolves are idempotent; a reply is not, so a run -// that may repeat one already posted (`reread`) first reads the PR and skips what this account -// already posted there. +// that may repeat one already posted (`reread`) first reads the reply threads, newest first, and the +// conversation since, and skips what this account already posted there. import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' -import type { PRComment } from '../../../shared/github/comment-types' +import type { + ReviewReplyPost, + ReviewReplyPosts +} from '../../github/client/fetch/review-reply-posts' import type { RuntimeReviewCommandSurface } from '../../runtime/runtime-review-command-surface' +import type { RuntimeGitHubReviewQueryCommands } from '../../runtime/runtime-github-review-query-commands' export type StructuredAgentSessionReviewRuntime = Pick< RuntimeReviewCommandSurface, @@ -13,11 +17,12 @@ export type StructuredAgentSessionReviewRuntime = Pick< | 'resolveGitLabRepoMRDiscussion' | 'addRepoPRReviewCommentReply' | 'addRepoIssueComment' - | 'getRepoPRComments' -> & { - /** The GitHub login the repo's writes run as; null when it cannot be told. */ - getRepoViewerLogin: (repoSelector: string) => Promise -} +> & + Pick + +/** How far before acceptance a post still counts as this message's: the host's clock and + * GitHub's can disagree by seconds. */ +export const REVIEW_REPLY_CLOCK_MARGIN_MS = 60_000 /** The checks panel's ceiling: the shared GitHub client keeps four calls in flight. */ const REVIEW_REPLY_CONCURRENCY = 4 @@ -33,8 +38,6 @@ export async function runStructuredAgentSessionReviewReply( acceptedAt: number /** A run that may follow an earlier one cut off before its receipt. */ reread: boolean - /** Who the repo's writes post as, asked only for a reread; null matches the text alone. */ - viewerLogin: (repoSelector: string) => Promise log: (message: string, error?: unknown) => void } ): Promise { @@ -62,21 +65,18 @@ async function gitHubWrites( options: { acceptedAt: number reread: boolean - viewerLogin: (repoSelector: string) => Promise log: (message: string, error?: unknown) => void } ): Promise { - const posted = options.reread ? await postedSince(runtime, repo, spec, options) : [] - const alreadyPosted = (body: string, at: { threadId?: string; path?: string } | null) => - posted.some( - (comment) => - comment.body.trim() === body.trim() && - (at === null - ? !comment.threadId && !comment.path - : at.threadId - ? comment.threadId === at.threadId - : comment.path === at.path) - ) + const posted = options.reread ? await postedSince(runtime, repo, spec, options) : null + // A reply with no thread to read, or no read at all, is posted: at worst once more, reported. + const alreadyPosted = (body: string, at: { threadId?: string } | null): boolean => { + if (!posted) { + return false + } + const comments = at === null ? posted.conversation : at.threadId && posted.threads[at.threadId] + return (comments || []).some((comment) => comment.body.trim() === body.trim()) + } const prRepo = spec.prRepo ?? null const conversationReply = spec.conversationReply return [ @@ -116,39 +116,45 @@ async function gitHubWrites( ] } -/** The PR's comments this account wrote since the agent took the message. A failed read answers - * none, which can post one reply twice; an unknown account matches any author. Both are reported, - * never blocking the rest. */ +/** What this account posted since shortly before the agent took the message, read from GitHub. A + * failed read answers null; an unknown account matches any author. Both are reported. */ async function postedSince( runtime: StructuredAgentSessionReviewRuntime, repo: string, spec: Extract, - options: { - acceptedAt: number - viewerLogin: (repoSelector: string) => Promise - log: (message: string, error?: unknown) => void - } -): Promise { + options: { acceptedAt: number; log: (message: string, error?: unknown) => void } +): Promise { if (spec.replies.length === 0 && !spec.conversationReply) { - return [] + return null } + const since = options.acceptedAt - REVIEW_REPLY_CLOCK_MARGIN_MS + let read: ReviewReplyPosts try { - const comments = await runtime.getRepoPRComments(repo, spec.prNumber, spec.prRepo ?? null, { - noCache: true + read = await runtime.getRepoReviewReplyPosts(repo, { + prNumber: spec.prNumber, + prRepo: spec.prRepo ?? null, + threadIds: spec.replies.flatMap((reply) => (reply.threadId ? [reply.threadId] : [])), + since: new Date(since).toISOString() }) - const login = await options.viewerLogin(repo) - if (login === null) { - options.log('review reply: the account is unknown, so a reply is matched by its text', null) - } - // GitHub stamps whole seconds. - const since = Math.floor(options.acceptedAt / 1000) * 1000 - return comments.filter( + } catch (error) { + options.log('review reply: could not read the PR before replying; a reply may repeat', error) + return null + } + const login = read.viewerLogin + if (login === null) { + options.log('review reply: the account is unknown, so a reply is matched by its text', null) + } + const mine = (comments: readonly ReviewReplyPost[]) => + comments.filter( (comment) => Date.parse(comment.createdAt) >= since && (login === null || comment.author === login) ) - } catch (error) { - options.log('review reply: could not read the PR before replying; a reply may repeat', error) - return [] + return { + viewerLogin: login, + threads: Object.fromEntries( + Object.entries(read.threads).map(([threadId, comments]) => [threadId, mine(comments)]) + ), + conversation: mine(read.conversation) } } diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index f50eeec22d1b..f1496d18e93d 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -191,8 +191,8 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis addRepoPRReviewCommentReply: (...args) => this.gitHubIssueComments.addRepoPRReviewCommentReply(...args), addRepoIssueComment: (...args) => this.gitHubIssueComments.addRepoIssueComment(...args), - getRepoPRComments: (...args) => this.gitHubReviewQueries.getRepoPRComments(...args), - getRepoViewerLogin: (repo) => this.gitHubReviewQueries.getRepoViewerLogin(repo) + getRepoReviewReplyPosts: (...args) => + this.gitHubReviewQueries.getRepoReviewReplyPosts(...args) } }) } diff --git a/src/main/runtime/runtime-github-review-query-commands.ts b/src/main/runtime/runtime-github-review-query-commands.ts index 9007eefc63a2..72b2b4915c6e 100644 --- a/src/main/runtime/runtime-github-review-query-commands.ts +++ b/src/main/runtime/runtime-github-review-query-commands.ts @@ -1,13 +1,11 @@ import type { GitHubOwnerRepo, GitHubPRFile } from '../../shared/github/pull-request-types' import type { Repo } from '../../shared/repo-types' import type { LocalProjectGhExecOptions } from '../project-runtime-git-options' +import { getIssue, getPRCheckDetails, getPRChecks, getPRComments } from '../github/client' import { - getAuthenticatedViewer, - getIssue, - getPRCheckDetails, - getPRChecks, - getPRComments -} from '../github/client' + getReviewReplyPosts, + type ReviewReplyPosts +} from '../github/client/fetch/review-reply-posts' import { getPRFileContents } from '../github/work-item-details' type LocalGitArgs = [] | [LocalProjectGhExecOptions] @@ -20,18 +18,19 @@ type RuntimeGitHubReviewQueryCommandsDeps = { export class RuntimeGitHubReviewQueryCommands { constructor(private readonly deps: RuntimeGitHubReviewQueryCommandsDeps) {} - /** The login this repo's GitHub writes run as: its bound account, else the signed-in gh user. - * Null where that user is not this machine's (SSH, WSL) or no one is signed in. */ - async getRepoViewerLogin(repoSelector: string): Promise { + /** What a review reply may already have posted on this PR, read as the repo's gh account; throws + * on any failure (`getReviewReplyPosts`). */ + async getRepoReviewReplyPosts( + repoSelector: string, + args: Parameters[1] + ): Promise { const repo = await this.deps.resolveRepo(repoSelector) - const [options] = this.deps.getLocalGitArgs(repo) - if (options?.ghAccount) { - return options.ghAccount.user - } - if (repo.connectionId || options?.wslDistro) { - return null - } - return (await getAuthenticatedViewer())?.login ?? null + return getReviewReplyPosts( + repo.path, + args, + repo.connectionId ?? null, + ...this.deps.getLocalGitArgs(repo) + ) } async getRepoIssue( diff --git a/src/main/runtime/runtime-github-review-viewer-login.test.ts b/src/main/runtime/runtime-github-review-viewer-login.test.ts deleted file mode 100644 index f85f5594cd3f..000000000000 --- a/src/main/runtime/runtime-github-review-viewer-login.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const viewer = vi.hoisted(() => vi.fn()) -vi.mock('../github/client', async (importOriginal) => ({ - ...(await importOriginal()), - getAuthenticatedViewer: viewer -})) - -import type { Repo } from '../../shared/repo-types' -import type { LocalProjectGhExecOptions } from '../project-runtime-git-options' -import { RuntimeGitHubReviewQueryCommands } from './runtime-github-review-query-commands' - -function commands(repo: Partial, options?: LocalProjectGhExecOptions) { - return new RuntimeGitHubReviewQueryCommands({ - resolveRepo: async (): Promise => ({ - id: 'repo-1', - path: '/repos/app', - displayName: 'app', - badgeColor: 'blue', - addedAt: 1, - ...repo - }), - getLocalGitArgs: () => (options ? [options] : []) - }) -} - -describe("the GitHub login a repo's writes run as", () => { - beforeEach(() => { - viewer.mockReset() - viewer.mockResolvedValue({ login: 'signed-in', email: null }) - }) - - it("is the repo's bound account", async () => { - const login = await commands( - {}, - { - ghAccount: { host: 'github.com', user: 'bound' } - } - ).getRepoViewerLogin('id:repo-1') - expect(login).toBe('bound') - expect(viewer).not.toHaveBeenCalled() - }) - - it('is the signed-in gh user otherwise', async () => { - await expect(commands({}).getRepoViewerLogin('id:repo-1')).resolves.toBe('signed-in') - }) - - it('is unknown where the signed-in user is not the one the writes run as', async () => { - await expect(commands({ connectionId: 'ssh-1' }).getRepoViewerLogin('x')).resolves.toBeNull() - await expect(commands({}, { wslDistro: 'Ubuntu' }).getRepoViewerLogin('x')).resolves.toBeNull() - expect(viewer).not.toHaveBeenCalled() - }) -}) From b0048a975c44b8d6a3dd6939c79248be34e6944c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:27:36 -0700 Subject: [PATCH 65/84] test(github): a thread read GitHub answers with errors beside data still throws --- src/main/github/client/fetch/review-reply-posts.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/github/client/fetch/review-reply-posts.test.ts b/src/main/github/client/fetch/review-reply-posts.test.ts index 660cceb1b4aa..19316ab1a57e 100644 --- a/src/main/github/client/fetch/review-reply-posts.test.ts +++ b/src/main/github/client/fetch/review-reply-posts.test.ts @@ -74,7 +74,10 @@ describe('what a review reply may already have posted', () => { }) it('throws when GitHub refuses either read, instead of reading as nothing posted', async () => { - answer({ data: null, errors: [{ message: 'Could not resolve to a node' }] }) + answer({ + data: { viewer: { login: 'me' } }, + errors: [{ message: 'Could not resolve to a node' }] + }) await expect(read()).rejects.toThrow('GitHub refused the thread read') mocks.gh.mockRejectedValue(new Error('HTTP 502')) From fe83eb77167ada00b5ccaa661dc65a6b2f2da681 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:28:37 -0700 Subject: [PATCH 66/84] test(cross-version): a send takes a review reply exactly where the host advertises running it --- ...ss-version-agent-session-wire.unit.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index c959f9212808..e5654b585f9f 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -28,6 +28,7 @@ import { AGENT_SESSION_CONVERSATION_STOP_RUNTIME_CAPABILITY, AGENT_SESSION_PENDING_SEND_RESULT_RUNTIME_CAPABILITY, AGENT_SESSION_QUESTION_ANSWERS_RUNTIME_CAPABILITY, + AGENT_SESSION_REVIEW_REPLY_RUNTIME_CAPABILITY, AGENT_SESSION_REWIND_RUNTIME_CAPABILITY, AGENT_SESSION_CONVERSATION_OUTLINE_RUNTIME_CAPABILITY, AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY, @@ -463,6 +464,38 @@ describe('cross-version structured agent sessions', () => { } }) + it('takes a review reply on a send exactly where the host advertises running it', async () => { + // The desktop attaches `reviewReply` only on this capability: an older host's strict send + // params refuse the field, and with it the whole launch prompt. + const method = 'agentSession.send' + const body = { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'fix these' }] } + const reviewReply = { provider: 'gitlab', repoId: 'repo-1', iid: 8, resolve: ['d-1'] } + const fields = { body, reviewReply } + const params = { envelope: envelope({ method, fields, fence: 1 }), ...fields } + expect(current.capabilities).toContain(AGENT_SESSION_REVIEW_REPLY_RUNTIME_CAPABILITY) + for (const build of [current, baseline]) { + if (!build.methodNames.includes(method)) { + continue + } + const advertised = build.capabilities.includes( + AGENT_SESSION_REVIEW_REPLY_RUNTIME_CAPABILITY + ) + const hostCalls = structuredHostStub(SESSION, WORKSPACE) + await build.installStructuredHost(installableHost(hostCalls)) + try { + const replies = await callBuild(build, method, params, { + clientKind: 'runtime', + clientCapabilities: current.capabilities + }) + expect(replies, `${build.label}: ${method} must answer exactly once`).toHaveLength(1) + expect(replies[0]?.ok, `${build.label}: ${JSON.stringify(replies[0])}`).toBe(advertised) + expect(hostCalls.send).toHaveBeenCalledTimes(advertised ? 1 : 0) + } finally { + await build.installStructuredHost(null) + } + } + }) + it( 'executes every method a release-shaped checkout registers', async () => { From f73c65ce0f1bb0eddf7798fdd323a6ebb0d03617 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:30:33 -0700 Subject: [PATCH 67/84] fix(native-chat): a review reply that wrote to a GitHub PR tells the desktop's views of it to refetch The host writes through the runtime's repo methods, which never sent the `gh:workItemMutated` notice the desktop's own writes do, so the checks panel kept showing the threads open with no "Fixing" reply and invited a second launch. After a run with any successful GitHub write, main now sends that notice for the repo and PR through a new runtime notifier hook. Only this machine's windows hear it: a paired client's panel refetches on its own triggers (limit). --- ...tured-agent-session-review-replies.test.ts | 19 ++++++++++++++++++- ...tured-agent-session-review-reply-runner.ts | 11 ++++++++++- .../runtime/orca-runtime-get-worktree-ps.ts | 11 ++++++++++- src/main/runtime/runtime-notifier-contract.ts | 2 ++ src/main/window/runtime-window-lifecycle.ts | 3 +++ 5 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts index 14bf3004f25c..a003ae88cf8c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts @@ -89,7 +89,8 @@ function reviewRuntime() { ), getRepoReviewReplyPosts: vi.fn( async () => onPR - ) + ), + reviewWritten: vi.fn(async () => {}) } } @@ -313,6 +314,8 @@ describe('a launch prompt with a review reply', () => { // A live accept needs no read of the PR, and all going through shows nothing in the chat. expect(review.getRepoReviewReplyPosts).not.toHaveBeenCalled() expect((await receipt(id)).line).toBeUndefined() + // The checks panel refetches the PR, as after its own writes. + expect(review.reviewWritten).toHaveBeenCalledExactlyOnceWith('id:repo-1', 42) // Later commits re-derive nothing. await launchPrompt({ provider: 'gitlab', repoId: 'repo-1', iid: 1, resolve: [] }) @@ -407,6 +410,20 @@ describe('a launch prompt with a review reply', () => { ['id:repo-2', 8, 'discussion-2', true] ]) expect(writes()).toBe(2) + expect(review.reviewWritten).not.toHaveBeenCalled() + }) + + it('tells no PR view to refetch when nothing was written', async () => { + review.resolveRepoReviewThread.mockResolvedValue(false) + review.addRepoPRReviewCommentReply.mockResolvedValue({ ok: false, error: 'HTTP 401' }) + review.addRepoIssueComment.mockResolvedValue({ ok: false, error: 'HTTP 401' }) + const id = await launchPrompt() + await handedOver(id) + await accept(id) + + await settledReceipt(id) + expect(writes()).toBe(3) + expect(review.reviewWritten).not.toHaveBeenCalled() }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-review-reply-runner.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-review-reply-runner.ts index 9e58e66bd795..f907184f2c43 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-review-reply-runner.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-review-reply-runner.ts @@ -18,7 +18,10 @@ export type StructuredAgentSessionReviewRuntime = Pick< | 'addRepoPRReviewCommentReply' | 'addRepoIssueComment' > & - Pick + Pick & { + /** Tells this machine's views of the PR to refetch, as the desktop's own writes do. */ + reviewWritten: (repoSelector: string, prNumber: number) => Promise + } /** How far before acceptance a post still counts as this message's: the host's clock and * GitHub's can disagree by seconds. */ @@ -55,6 +58,12 @@ export async function runStructuredAgentSessionReviewReply( }) : await gitHubWrites(runtime, repo, spec, options) const failures = await runBounded(writes, options.log) + // GitLab views already poll; a GitHub PR view refetches only when told. + if (spec.provider === 'github' && failures.length < writes.length) { + await runtime + .reviewWritten(repo, spec.prNumber) + .catch((error: unknown) => options.log('review reply: the PR view was not told', error)) + } return failures[0] ?? null } diff --git a/src/main/runtime/orca-runtime-get-worktree-ps.ts b/src/main/runtime/orca-runtime-get-worktree-ps.ts index f1496d18e93d..c83dffa1436c 100644 --- a/src/main/runtime/orca-runtime-get-worktree-ps.ts +++ b/src/main/runtime/orca-runtime-get-worktree-ps.ts @@ -192,7 +192,16 @@ export class OrcaRuntimeWithGetWorktreePs extends OrcaRuntimeWithStartTuiIdleVis this.gitHubIssueComments.addRepoPRReviewCommentReply(...args), addRepoIssueComment: (...args) => this.gitHubIssueComments.addRepoIssueComment(...args), getRepoReviewReplyPosts: (...args) => - this.gitHubReviewQueries.getRepoReviewReplyPosts(...args) + this.gitHubReviewQueries.getRepoReviewReplyPosts(...args), + // Only this machine's windows hear it; a paired client refetches on its own triggers. + reviewWritten: async (repoSelector, prNumber) => { + const repo = await this.resolveRepoSelector(repoSelector) + this.notifier?.githubPullRequestMutated?.({ + repoPath: repo.path, + repoId: repo.id, + number: prNumber + }) + } } }) } diff --git a/src/main/runtime/runtime-notifier-contract.ts b/src/main/runtime/runtime-notifier-contract.ts index c5b279e41db2..02ed3bec5af2 100644 --- a/src/main/runtime/runtime-notifier-contract.ts +++ b/src/main/runtime/runtime-notifier-contract.ts @@ -29,6 +29,8 @@ export type RuntimeNotifier = { worktreesChanged(repoId: string, renamed?: { oldWorktreeId: string; newWorktreeId: string }): void worktreeBaseStatus?(event: WorktreeBaseStatusEvent): void worktreeRemoteBranchConflict?(event: WorktreeRemoteBranchConflictEvent): void + /** The host wrote to a GitHub PR on a user's behalf; views of it refetch, as after their own. */ + githubPullRequestMutated?(payload: { repoPath: string; repoId: string; number: number }): void reposChanged(): void activateWorktree( repoId: string, diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index 0322a816d709..846cc0e71b11 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -17,6 +17,7 @@ import { registerRendererDocumentNavigation } from './renderer-document-navigati import { createRuntimeRendererNotificationSender } from './runtime-renderer-notification-sender' import { requestSessionTabCloseFromRenderer } from './session-tab-close-request-relay' import { requestTerminalTabCloseFromRenderer } from './terminal-tab-close-request-relay' +import { broadcastGitHubWorkItemMutation } from '../ipc/github-work-item-mutation-events' let runtimeNotifierTokenCounter = 0 let activeRuntimeNotifierToken: number | null = null @@ -43,6 +44,8 @@ export function registerRuntimeWindowLifecycle( }, worktreeBaseStatus: (event) => send('worktree:baseStatus', event), worktreeRemoteBranchConflict: (event) => send('worktree:remoteBranchConflict', event), + githubPullRequestMutated: (payload) => + broadcastGitHubWorkItemMutation({ ...payload, type: 'pr' }), reposChanged: () => send('repos:changed'), automationsChanged: (payload) => send('automations:changed', payload), activateWorktree: ( From db7edcae40a2eca115c3eaf62ee28992922e4f70 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:32:53 -0700 Subject: [PATCH 68/84] fix(checks-panel): a structured chat that still holds an unrecorded launch prompt owns its review reply If a structured launch's first send was refused or lost, or its create failed but kept the chat's outbox for the relaunch, the panel handed the comments back, said "Could not start the selected agent." and left Start enabled, while the chat still sent the message (or offered its Retry) with the review reply on it. A second Start then posted a second set of public replies. The launch's delivery result now says when the chat still holds the prompt, and the panel then treats the comments as handed off: selection cleared, dialog closed, no toast; the chat's Retry owns it. With nothing held, the comments still go back to the panel as before. --- .../comment-resolution-review-reply.test.tsx | 42 ++++++++++++++++++- .../runSourceControlAgentActionStart.ts | 5 ++- .../src/lib/launch-agent-in-new-tab.ts | 4 +- ...ctured-agent-session-launch-prompt.test.ts | 37 ++++++++++++++++ .../structured-agent-session-launch-prompt.ts | 32 +++++++++++++- 5 files changed, 115 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx index 0c0956837b35..2302b9b6cd6f 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx @@ -122,11 +122,12 @@ function resolveCommentsWithAi(hook: ReturnType['hook']) onLaunchAborted: hook.result.current.handleLaunchAborted, onLaunched: (launch) => hook.result.current.consumeClaimedCommentResolutionAfterDeliveryRef.current(launch), - onClose: vi.fn() + onClose }) ) } +const onClose = vi.fn() const DELIVERED = Promise.resolve({ delivered: true, failureNotified: false }) describe('Resolve comments with AI', () => { @@ -168,6 +169,45 @@ describe('Resolve comments with AI', () => { expect(model.pendingCommentResolutionRef.current).toBeNull() }) + it('hands the comments to a chat that still holds its unrecorded prompt, and offers no second Start', async () => { + const { model, hook } = acknowledgement() + mocks.launchAgentInNewTab.mockReturnValue({ + surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'session-1' }, + promptDeliveryResult: Promise.resolve({ + delivered: false, + failureNotified: false, + heldByChat: true + }), + structuredSettlement: Promise.resolve({ kind: 'launched' }) + }) + + await expect(resolveCommentsWithAi(hook)).resolves.toBe(true) + + expect(mocks.toastError).not.toHaveBeenCalled() + expect(onClose).toHaveBeenCalledOnce() + expect(model.resolveReviewThread).not.toHaveBeenCalled() + expect(model.addPRConversationComment).not.toHaveBeenCalled() + expect(model.setCommentsSelectionClearRequest).toHaveBeenCalledOnce() + expect(model.pendingCommentResolutionRef.current).toBeNull() + }) + + it('hands the comments back when the chat holds nothing to send again', async () => { + const { model, hook } = acknowledgement() + mocks.launchAgentInNewTab.mockReturnValue({ + surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'session-1' }, + promptDeliveryResult: Promise.resolve({ delivered: false, failureNotified: false }), + structuredSettlement: Promise.resolve({ kind: 'launched' }) + }) + + await expect(resolveCommentsWithAi(hook)).resolves.toBe(false) + + expect(mocks.toastError).toHaveBeenCalledOnce() + expect(onClose).not.toHaveBeenCalled() + expect(model.pendingCommentResolutionRef.current).toMatchObject({ + reviewContextKey: REVIEW_KEY + }) + }) + it('writes once from the panel when the chat runs on a host that cannot carry the reply', async () => { mocks.hostRunsReviewReplies.mockResolvedValueOnce(false) const { model, hook } = acknowledgement() diff --git a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts index 20ffc1e91452..f160d84e4db3 100644 --- a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts @@ -139,7 +139,10 @@ export async function runSourceControlAgentActionStart({ if (result?.promptDeliveryResult) { try { const deliveryResult = await result.promptDeliveryResult - launched = deliveryResult.delivered + // A chat that holds its review reply sends it again itself; offering Start again here + // would launch a second chat, and post the replies twice. + launched = + deliveryResult.delivered || (reviewReplyCarried && deliveryResult.heldByChat === true) launchFailureNotified = deliveryResult.failureNotified } catch (error) { console.error('promptDeliveryResult rejected', error) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index 8d1110c03380..d26c6250fa03 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -27,6 +27,7 @@ import { resolveInitialNativeChatSessionOptions } from '@/components/native-chat import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache' import { launchStructuredAgentFromNewTab } from '@/lib/launch-agent-in-new-tab-structured-route' import type { StructuredAgentLaunchSettlement } from '@/lib/structured-agent-launch-settlement' +import type { StructuredPromptDeliveryResult } from '@/lib/structured-agent-session-launch-prompt' import { workspaceKindForWorktreeId } from '@/lib/agent-launch-route-input' import { planAgentSessionLaunch, @@ -83,7 +84,8 @@ export type LaunchAgentInNewTabResult = { surface: AgentLaunchSurface startupPlan: AgentStartupPlan pasteDraftAfterLaunch: boolean - promptDeliveryResult?: Promise<{ delivered: boolean; failureNotified: boolean }> + /** `heldByChat` comes only from a structured chat's launch prompt. */ + promptDeliveryResult?: Promise /** Structured route only: what the launch did once it settled. The call stays synchronous. */ structuredSettlement?: Promise } | null diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts index 41732df5e69b..f2b9206e571d 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts @@ -91,3 +91,40 @@ describe('settleStructuredAgentLaunchPrompt', () => { ) }) }) + +describe('a launch prompt the host did not record', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + }) + + it('is held by its chat when the chat keeps it to send again, and not when nothing is kept', async () => { + const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'review this') + mocks.call.mockResolvedValue({ + ok: false, + refusal: { code: 'agent_session_checkpoint_stale', message: 'stale' } + }) + const settle = (launchResult: Promise<{ sessionId: string; fence: number }>) => + settleStructuredAgentLaunchPrompt({ + launchResult, + target: { kind: 'local' }, + options: { prompt: 'review this' }, + stagedEntry + }) + + await expect(settle(Promise.resolve({ sessionId: 'session-1', fence: 1 }))).resolves.toEqual({ + delivered: false, + failureNotified: false, + heldByChat: true + }) + // A failed create that kept the outbox for its relaunch still holds it. + await expect(settle(Promise.reject(new Error('create failed')))).resolves.toMatchObject({ + heldByChat: true + }) + + mutateStructuredAgentSessionLaunchPrompt('session-1', stagedEntry!.clientMessageId, () => null) + await expect(settle(Promise.reject(new Error('create failed')))).rejects.toThrow( + 'create failed' + ) + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index 9540c844ab3b..71c47e9f7f91 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -11,6 +11,7 @@ import { import { agentSessionRefusalFailure } from '../../../shared/agent-session-write-failure' import { createStructuredAgentSessionOperationId } from '../../../shared/structured-agent-session-mutation' import { + getStructuredAgentSessionOutbox, mutateStructuredAgentSessionLaunchPrompt, type StructuredAgentSessionLaunchPromptMutation } from '@/components/native-chat/structured-agent-session-outbox-storage' @@ -21,6 +22,20 @@ import { createBrowserUuid } from '@/lib/browser-uuid' export type StructuredPromptDeliveryResult = { delivered: boolean failureNotified: boolean + /** Not delivered, but its chat still holds it and sends it again or offers its Retry: the chat + * owns it from here, so the caller does not offer it again. */ + heldByChat?: true +} + +/** Whether the chat still holds this launch prompt, under its first id or one a refusal rotated. */ +function chatHoldsLaunchPrompt(entry: StructuredAgentSessionOutboxEntry | null): boolean { + const body = entry && JSON.stringify(entry.body) + return Boolean( + entry && + getStructuredAgentSessionOutbox(entry.sessionId).some( + (held) => held.source === 'launch' && JSON.stringify(held.body) === body + ) + ) } export type StructuredLaunchPromptOptions = { @@ -163,7 +178,12 @@ export function settleStructuredAgentLaunchPrompt(args: { if (args.options.promptDelivery === 'draft' || !args.options.prompt?.trim()) { return undefined } - return args.launchResult.then(async (receipt) => { + const held = (): StructuredPromptDeliveryResult => ({ + delivered: false, + failureNotified: false, + heldByChat: true + }) + const settled = args.launchResult.then(async (receipt) => { if (!args.stagedEntry) { return { delivered: false, failureNotified: true } } @@ -177,7 +197,15 @@ export function settleStructuredAgentLaunchPrompt(args: { const delivered = await dispatch.promise if (delivered) { args.options.onPromptDelivered?.() + return { delivered, failureNotified: false } + } + return chatHoldsLaunchPrompt(entry) ? held() : { delivered, failureNotified: false } + }) + // A create that failed but kept the chat's outbox for its relaunch still owns the prompt. + return settled.catch((error: unknown) => { + if (chatHoldsLaunchPrompt(args.stagedEntry)) { + return held() } - return { delivered, failureNotified: false } + throw error }) } From 710cdf690b6b69a1bca9cd96b8b1075448864a5c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:34:03 -0700 Subject: [PATCH 69/84] fix(checks-panel): Resolve comments with AI says once when a structured chat can't reply on the PR Without the PR's repository the chat's host can resolve the threads but not post the replies, and the structured route dropped them silently where the panel used to say so. It now shows the panel's existing "Could not resolve the GitHub PR to reply on." once, as the launch hands off. The other two cases need no new line: with no resolve target, or a reply too large to carry, nothing rides the message and the panel writes and reports as before. --- .../comment-resolution-review-reply.test.tsx | 25 +++++++++++++++++-- .../use-checks-panel-ai-acknowledgement.tsx | 19 +++++++++++--- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx index 2302b9b6cd6f..81e4fc94cfc0 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx @@ -69,8 +69,7 @@ function resolution(): PendingPRCommentAiAck { type AcknowledgementInput = Parameters[0] -function acknowledgement() { - const pending = resolution() +function acknowledgement(pending: PendingPRCommentAiAck = resolution()) { setPendingPRCommentAiAck(pending) const model = { addPRConversationComment: vi.fn(async () => ({ @@ -208,6 +207,28 @@ describe('Resolve comments with AI', () => { }) }) + it("says once, at launch, that it can't reply without the PR's repository", async () => { + const { githubTarget: _missing, ...withoutReplyTarget } = resolution() + const { model, hook } = acknowledgement(withoutReplyTarget) + mocks.launchAgentInNewTab.mockReturnValue({ + surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'session-1' }, + promptDeliveryResult: DELIVERED, + structuredSettlement: Promise.resolve({ kind: 'launched' }) + }) + + await expect(resolveCommentsWithAi(hook)).resolves.toBe(true) + + expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith( + expect.objectContaining({ + reviewReply: expect.objectContaining({ replies: [], resolve: ['T1'] }) + }) + ) + expect(mocks.toastError).toHaveBeenCalledExactlyOnceWith( + 'Could not resolve the GitHub PR to reply on.' + ) + expect(model.resolveReviewThread).not.toHaveBeenCalled() + }) + it('writes once from the panel when the chat runs on a host that cannot carry the reply', async () => { mocks.hostRunsReviewReplies.mockResolvedValueOnce(false) const { model, hook } = acknowledgement() diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx index 11828ca9aac2..e77c349ad64f 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx @@ -44,6 +44,12 @@ type ChecksPanelAiAcknowledgementInput = Pick< Pick & Pick +const cannotReplyOnPR = (): string => + translate( + 'auto.components.right.sidebar.ChecksPanel.7e4b2a19c0', + 'Could not resolve the GitHub PR to reply on.' + ) + export function useChecksPanelAiAcknowledgement(model: ChecksPanelAiAcknowledgementInput) { const { addPRConversationComment, @@ -102,10 +108,7 @@ export function useChecksPanelAiAcknowledgement(model: ChecksPanelAiAcknowledgem resolution.provider === 'github' && githubTarget == null && hasPRCommentGroupNeedingReply(resolution.selectedGroups) - ? translate( - 'auto.components.right.sidebar.ChecksPanel.7e4b2a19c0', - 'Could not resolve the GitHub PR to reply on.' - ) + ? cannotReplyOnPR() : undefined const resolveSnapshottedThread = buildSnapshottedThreadResolver({ provider: resolution.provider, @@ -328,6 +331,14 @@ export function useChecksPanelAiAcknowledgement(model: ChecksPanelAiAcknowledgem if (launch?.reviewReplyCarried) { clearSentCommentSelection(resolution.reviewContextKey) setCommentResolutionAckBusyNow(false) + // The one write the chat's host can't make: without the PR's repository it can't reply. + if ( + resolution.provider === 'github' && + !resolution.githubTarget && + hasPRCommentGroupNeedingReply(resolution.selectedGroups) + ) { + toast.error(cannotReplyOnPR()) + } return } setCommentResolutionAckBusyNow(true) From 6d829bf0372ccc463517d7078e60d326f34eb974 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:37:14 -0700 Subject: [PATCH 70/84] fix(i18n): desktop AI-fix toasts say the work was sent to an agent, not that one started A structured chat now starts at rest and its first message starts the agent, so "Started an AI agent for ..." fired before any agent had started, and that start can still fail. The fix-checks, conflicts, recovery and check-details toasts now say "Sent to an AI agent for ...", in all six catalogs. The checks panel's comments now say who writes the review reply on each route. --- .../src/components/editor/CheckRunDetailsPanel.tsx | 2 +- .../editor/check-run-details-fix-with-ai.test.ts | 3 +++ .../editor/check-run-details-fix-with-ai.ts | 2 +- .../inspect-pull-request/checks-tab-actions.ts | 2 +- .../pull-request-page/checks/fix-dialog.tsx | 2 +- .../pull-request-page/checks/fix-launch.ts | 2 +- .../right-sidebar/checks-panel/active-content.tsx | 13 ++++++++----- .../use-checks-panel-check-and-review-actions.tsx | 2 +- .../source-control/ai/recovery-launch.ts | 2 +- .../source-control/panel/dialog-layer.tsx | 2 +- src/renderer/src/i18n/en-runtime-required.json | 2 +- src/renderer/src/i18n/locales/en.json | 14 +++++++------- src/renderer/src/i18n/locales/es.json | 14 +++++++------- src/renderer/src/i18n/locales/fr.json | 14 +++++++------- src/renderer/src/i18n/locales/ja.json | 14 +++++++------- src/renderer/src/i18n/locales/ko.json | 14 +++++++------- src/renderer/src/i18n/locales/zh.json | 14 +++++++------- 17 files changed, 62 insertions(+), 56 deletions(-) diff --git a/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx b/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx index 61953989b364..c1a0c8e05824 100644 --- a/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx +++ b/src/renderer/src/components/editor/CheckRunDetailsPanel.tsx @@ -195,7 +195,7 @@ export function CheckRunDetailsPanel({ toast.success( translate( 'auto.components.editor.check.run.details.fix.with.ai.2ef90c9819', - 'Started an AI agent for this check.' + 'Sent to an AI agent for this check.' ) ) } diff --git a/src/renderer/src/components/editor/check-run-details-fix-with-ai.test.ts b/src/renderer/src/components/editor/check-run-details-fix-with-ai.test.ts index 029ba1d0decd..2795bc853b15 100644 --- a/src/renderer/src/components/editor/check-run-details-fix-with-ai.test.ts +++ b/src/renderer/src/components/editor/check-run-details-fix-with-ai.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' import type { PRCheckDetail, PRCheckRunDetails } from '../../../../shared/github/check-types' import type { PRInfo } from '../../../../shared/github/pull-request-types' import type { Repo } from '../../../../shared/repo-types' @@ -210,5 +211,7 @@ describe('check-run-details-fix-with-ai', () => { }) ) expect(startFixChecksAgent.mock.calls[0]?.[0]?.basePrompt).toContain('assertion failed') + // Said once the chat has the prompt: its agent may still be starting. + expect(toast.success).toHaveBeenCalledWith('Sent to an AI agent for this check.') }) }) diff --git a/src/renderer/src/components/editor/check-run-details-fix-with-ai.ts b/src/renderer/src/components/editor/check-run-details-fix-with-ai.ts index 394bcac8123e..eb3ab95074e4 100644 --- a/src/renderer/src/components/editor/check-run-details-fix-with-ai.ts +++ b/src/renderer/src/components/editor/check-run-details-fix-with-ai.ts @@ -89,7 +89,7 @@ export async function startCheckRunDetailsFixWithAI(args: { toast.success( translate( 'auto.components.editor.check.run.details.fix.with.ai.2ef90c9819', - 'Started an AI agent for this check.' + 'Sent to an AI agent for this check.' ) ) } diff --git a/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-actions.ts b/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-actions.ts index 21b10c2cd888..22c98e8b71db 100644 --- a/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-actions.ts +++ b/src/renderer/src/components/github-item-dialog/inspect-pull-request/checks-tab-actions.ts @@ -244,7 +244,7 @@ export async function fixBrokenGitHubChecks({ toast.success( translate( 'auto.components.GitHubItemDialog.28986b3747', - 'Started an AI agent for the broken checks.' + 'Sent to an AI agent for the broken checks.' ) ) } diff --git a/src/renderer/src/components/pull-request-page/checks/fix-dialog.tsx b/src/renderer/src/components/pull-request-page/checks/fix-dialog.tsx index f43c6f6be53a..74ea939b48d1 100644 --- a/src/renderer/src/components/pull-request-page/checks/fix-dialog.tsx +++ b/src/renderer/src/components/pull-request-page/checks/fix-dialog.tsx @@ -59,7 +59,7 @@ export function ChecksFixDialog({ toast.success( translate( 'auto.components.PullRequestPage.85e62c5266', - 'Started an AI agent for the broken checks.' + 'Sent to an AI agent for the broken checks.' ) ) }} diff --git a/src/renderer/src/components/pull-request-page/checks/fix-launch.ts b/src/renderer/src/components/pull-request-page/checks/fix-launch.ts index 3944540ef66e..80272aee4938 100644 --- a/src/renderer/src/components/pull-request-page/checks/fix-launch.ts +++ b/src/renderer/src/components/pull-request-page/checks/fix-launch.ts @@ -119,7 +119,7 @@ export async function fixBrokenPullRequestChecks(args: { toast.success( translate( 'auto.components.PullRequestPage.85e62c5266', - 'Started an AI agent for the broken checks.' + 'Sent to an AI agent for the broken checks.' ) ) } diff --git a/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx b/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx index b4643874d689..cdd3cbe4336c 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/active-content.tsx @@ -328,30 +328,33 @@ export function ChecksPanelActiveContent({ : null } onSaveAgentDefault={saveLaunchActionDefault} - // Why: claims the ack payload when the tab exists; the host writes still wait for delivery. + // Why: a structured chat's launch prompt carries the review writes for its host to make. reviewReply={buildLaunchReviewReply} + // Why: claims the ack payload when the tab exists; the writes still wait for delivery. onLaunchAccepted={handleLaunchAccepted} onLaunchAborted={handleLaunchAborted} onLaunched={(launch) => { - // Why: prompt delivery succeeded — the only point at which host replies/resolves may run. + // Why: the prompt was delivered (a paste landed, or a chat recorded its message): the panel + // writes now, unless the chat's message carries the writes for its host. consumeClaimedCommentResolutionAfterDeliveryRef.current(launch) if (agentComposerState?.actionId === 'resolveConflicts') { toast.success( translate( 'auto.components.right.sidebar.ChecksPanel.a0181a8d76', - 'Started an AI agent for the conflicts.' + 'Sent to an AI agent for the conflicts.' ) ) return } if (agentComposerState?.actionId === 'resolveComments') { - // Why: resolve/reply toast is emitted by resolveSelectedThreadsAfterLaunch. + // Why: a terminal's writes report from resolveSelectedThreadsAfterLaunch; a chat's from + // its own failure line. return } toast.success( translate( 'auto.components.right.sidebar.ChecksPanel.2ef90c9819', - 'Started an AI agent for the broken checks.' + 'Sent to an AI agent for the broken checks.' ) ) }} diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx index b9cc54d1a18a..66625156f264 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-check-and-review-actions.tsx @@ -140,7 +140,7 @@ export function useChecksPanelCheckAndReviewActions(model: ChecksPanelCheckAndRe toast.success( translate( 'auto.components.right.sidebar.ChecksPanel.2ef90c9819', - 'Started an AI agent for the broken checks.' + 'Sent to an AI agent for the broken checks.' ) ) } diff --git a/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts b/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts index 4a055a613eec..9f4629fdaa33 100644 --- a/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts +++ b/src/renderer/src/components/right-sidebar/source-control/ai/recovery-launch.ts @@ -68,7 +68,7 @@ export function getDefaultSourceControlRecoveryLaunchCopy( ), success: translate( 'auto.components.right.sidebar.source.control.ai.recovery.launch.success', - 'Started an AI agent for the {{value0}} failure.', + 'Sent to an AI agent for the {{value0}} failure.', { value0: subject } ) } diff --git a/src/renderer/src/components/right-sidebar/source-control/panel/dialog-layer.tsx b/src/renderer/src/components/right-sidebar/source-control/panel/dialog-layer.tsx index 560651684426..21229013a48b 100644 --- a/src/renderer/src/components/right-sidebar/source-control/panel/dialog-layer.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/panel/dialog-layer.tsx @@ -207,7 +207,7 @@ export function SourceControlDialogLayer({ toast.success( translate( 'auto.components.right.sidebar.SourceControl.e48caaf0dd', - 'Started an AI agent for the conflicts.' + 'Sent to an AI agent for the conflicts.' ) ) } diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index a3ee63866b69..71f8d063136e 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2888,7 +2888,6 @@ "openCurrentConversation": "Open the current conversation to continue.", "optionRejected": "The agent didn't accept this setting.", "outcomeUnknown": "Orca couldn't confirm what happened. Check the chat.", - "sendOutcomeLost": "Orca couldn't confirm your message reached the agent. Check the chat, then send it again if needed.", "ownerUnproven": "The previous agent in this chat may still be running.", "promptPending": "The agent is waiting for an answer to a question or approval.", "questionChanged": "This question was already answered or has changed.", @@ -2898,6 +2897,7 @@ "reopenChat": "Reopen the chat to check again.", "restartFailed": "The agent couldn't restart.", "savedByNewerOrca": "Chats were saved by a newer Orca.", + "sendOutcomeLost": "Orca couldn't confirm your message reached the agent. Check the chat, then send it again if needed.", "settleEarlierMessage": "Wait for your earlier message to go through, or retry it.", "startNewChat": "Start a new chat to continue.", "terminalAgentHoldsChat": "This chat is still open in a terminal agent.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 937702566f4f..3428b0687dea 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1338,7 +1338,7 @@ "f4b1292569": "Start the default AI agent on these checks", "9a1004fc76": "Refresh checks", "03e542fcfe": "Failed to start an AI agent for the broken checks: {{value0}}", - "28986b3747": "Started an AI agent for the broken checks.", + "28986b3747": "Sent to an AI agent for the broken checks.", "1690fd7f4a": "No broken checks to fix.", "9e7c221b8d": "Failed to rerun checks", "e463ec935f": "Check reruns requested", @@ -1842,7 +1842,7 @@ "a18d01cda3": "No checks reported yet", "3912daf310": "This pull request has no reported checks yet.", "45877f5089": "No checks found", - "85e62c5266": "Started an AI agent for the broken checks.", + "85e62c5266": "Sent to an AI agent for the broken checks.", "ddfd42f460": "Review the prompt before starting an agent.", "a053bdd082": "Fix Broken Checks With AI", "1b14d0a69c": "Open in GitHub", @@ -12342,8 +12342,8 @@ "60ed678138": "selected" }, "ChecksPanel": { - "2ef90c9819": "Started an AI agent for the broken checks.", - "a0181a8d76": "Started an AI agent for the conflicts.", + "2ef90c9819": "Sent to an AI agent for the broken checks.", + "a0181a8d76": "Sent to an AI agent for the conflicts.", "34464d00b9": "updated", "058039787c": "Cancel", "2ab7fd4b6d": "Save", @@ -12697,7 +12697,7 @@ "f4c766f1ca": "Choose the agent and command template for this run.", "1a6a6e0bc5": "Generate Hosted Review Details", "6b122529d4": "Generate Commit Message", - "e48caaf0dd": "Started an AI agent for the conflicts.", + "e48caaf0dd": "Sent to an AI agent for the conflicts.", "901140f47d": "Review the prompt before starting an agent.", "19652ddd76": "Resolve Conflicts With AI", "c9ad22888e": "Pick the branch compare target for this repository.", @@ -13293,7 +13293,7 @@ "9bbd9077a2": "No enabled AI agents. Configure agents in Settings.", "5540ff50cc": "Could not build the agent launch command.", "216f762bd7": "Unable to resolve the workspace connection.", - "success": "Started an AI agent for the {{value0}} failure." + "success": "Sent to an AI agent for the {{value0}} failure." } } }, @@ -15638,7 +15638,7 @@ "4f2d9a8c17": "Select a repository before launching an AI action.", "7c3e1b5d42": "Open a PR or MR before launching an AI fix.", "9b2f6d4a81": "This check is not failing.", - "2ef90c9819": "Started an AI agent for this check." + "2ef90c9819": "Sent to an AI agent for this check." } } } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 36f63d36c8c0..de6d46ff302f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -983,7 +983,7 @@ "f4b1292569": "Iniciar el agente de IA predeterminado en estos checks", "9a1004fc76": "Actualizar checks", "03e542fcfe": "No se pudo iniciar un agente de IA para los checks fallidos: {{value0}}", - "28986b3747": "Se inició un agente de IA para los checks fallidos.", + "28986b3747": "Se envió a un agente de IA para los checks fallidos.", "1690fd7f4a": "No hay checks fallidos que arreglar.", "9e7c221b8d": "No se pudieron volver a ejecutar los checks", "e463ec935f": "Se solicitó volver a ejecutar los checks", @@ -1467,7 +1467,7 @@ "a18d01cda3": "Aún no se reportaron checks", "3912daf310": "Este pull request aún no tiene checks reportados.", "45877f5089": "No se encontraron checks", - "85e62c5266": "Se inició un agente de IA para los checks fallidos.", + "85e62c5266": "Se envió a un agente de IA para los checks fallidos.", "ddfd42f460": "Revisa el prompt antes de iniciar un agente.", "a053bdd082": "Arreglar checks fallidos con IA", "1b14d0a69c": "Abrir en GitHub", @@ -10522,8 +10522,8 @@ "60ed678138": "seleccionados" }, "ChecksPanel": { - "2ef90c9819": "Se inició un agente de IA para los checks con errores.", - "a0181a8d76": "Se inició un agente de IA para los conflictos.", + "2ef90c9819": "Se envió a un agente de IA para los checks con errores.", + "a0181a8d76": "Se envió a un agente de IA para los conflictos.", "34464d00b9": "actualizado", "058039787c": "Cancelar", "2ab7fd4b6d": "Guardar", @@ -10857,7 +10857,7 @@ "f4c766f1ca": "Elija el agente y la plantilla de comando para esta ejecución.", "1a6a6e0bc5": "Generar detalles de reseña alojada", "6b122529d4": "Generar mensaje de commit", - "e48caaf0dd": "Inició un agente de IA para los conflictos.", + "e48caaf0dd": "Se envió a un agente de IA para los conflictos.", "901140f47d": "Revisa el prompt antes de iniciar un agente.", "19652ddd76": "Resolver conflictos con IA", "c9ad22888e": "Elige el destino de comparación de ramas para este repositorio.", @@ -11427,7 +11427,7 @@ "9bbd9077a2": "No hay agentes de AI habilitados. Configura agentes en Configuración.", "5540ff50cc": "No se pudo construir el comando de inicio del agente.", "216f762bd7": "No se pudo resolver la conexión del espacio de trabajo.", - "success": "Se inició un agente de AI para el fallo de {{value0}}." + "success": "Se envió a un agente de IA para el fallo de {{value0}}." } } }, @@ -13568,7 +13568,7 @@ "4f2d9a8c17": "Selecciona un repositorio antes de lanzar una acción de IA.", "7c3e1b5d42": "Abre un PR o MR antes de lanzar un fix con IA.", "9b2f6d4a81": "Este check no está fallando.", - "2ef90c9819": "Se inició un agente de IA para este check." + "2ef90c9819": "Se envió a un agente de IA para este check." } } } diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 01558dbf29a7..5b089d9e2293 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -1291,7 +1291,7 @@ "f4b1292569": "Démarrer l'agent IA par défaut sur ces vérifications", "9a1004fc76": "Actualiser les vérifications", "03e542fcfe": "Échec du démarrage d'un agent IA pour les vérifications cassées : {{value0}}", - "28986b3747": "Un agent IA a été démarré pour les vérifications cassées.", + "28986b3747": "Envoyé à un agent IA pour les vérifications cassées.", "1690fd7f4a": "Aucune vérification cassée à corriger.", "9e7c221b8d": "Échec de la relance des vérifications", "e463ec935f": "Relances des vérifications demandées", @@ -1795,7 +1795,7 @@ "a18d01cda3": "Aucune vérification signalée pour l'instant", "3912daf310": "Cette pull request n'a aucune vérification signalée pour l'instant.", "45877f5089": "Aucune vérification trouvée", - "85e62c5266": "Un agent IA a été démarré pour les vérifications cassées.", + "85e62c5266": "Envoyé à un agent IA pour les vérifications cassées.", "ddfd42f460": "Vérifiez le prompt avant de démarrer un agent.", "a053bdd082": "Corriger les vérifications en échec avec l'IA", "1b14d0a69c": "Ouvrir dans GitHub", @@ -12179,8 +12179,8 @@ "60ed678138": "sélectionnés" }, "ChecksPanel": { - "2ef90c9819": "Un agent IA a été démarré pour les vérifications cassées.", - "a0181a8d76": "Un agent IA a été lancé pour les conflits.", + "2ef90c9819": "Envoyé à un agent IA pour les vérifications cassées.", + "a0181a8d76": "Envoyé à un agent IA pour les conflits.", "34464d00b9": "mis à jour", "058039787c": "Annuler", "2ab7fd4b6d": "Enregistrer", @@ -12534,7 +12534,7 @@ "f4c766f1ca": "Choisissez l'agent et le modèle de commande pour cette exécution.", "1a6a6e0bc5": "Générer les détails de la revue hébergée", "6b122529d4": "Générer le message de commit", - "e48caaf0dd": "Un agent IA a été lancé pour les conflits.", + "e48caaf0dd": "Envoyé à un agent IA pour les conflits.", "901140f47d": "Vérifiez le prompt avant de démarrer un agent.", "19652ddd76": "Résoudre les conflits avec l'IA", "c9ad22888e": "Choisissez la cible de comparaison de branches pour ce dépôt.", @@ -13130,7 +13130,7 @@ "9bbd9077a2": "Aucun agent IA activé. Configurez les agents dans les paramètres.", "5540ff50cc": "Impossible de construire la commande de lancement de l'agent.", "216f762bd7": "Impossible de résoudre la connexion de l'espace de travail.", - "success": "Un agent IA a été lancé pour l'échec de {{value0}}." + "success": "Envoyé à un agent IA pour l'échec de {{value0}}." } } }, @@ -15475,7 +15475,7 @@ "4f2d9a8c17": "Sélectionnez un dépôt avant de lancer une action IA.", "7c3e1b5d42": "Ouvrez une pull request ou une MR avant de lancer une correction IA.", "9b2f6d4a81": "Cette vérification n'est pas en échec.", - "2ef90c9819": "Un agent IA a été démarré pour cette vérification." + "2ef90c9819": "Envoyé à un agent IA pour cette vérification." } } } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c3a7aeb5de14..39251ddebc06 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1189,7 +1189,7 @@ "f4b1292569": "これらのチェックでデフォルトの AI Agent を開始します", "9a1004fc76": "リフレッシュチェック", "03e542fcfe": "失敗したチェックのため AI Agent を開始できませんでした: {{value0}}", - "28986b3747": "失敗したチェックに対して AI Agent を開始しました。", + "28986b3747": "失敗したチェックを AI Agent に送信しました。", "1690fd7f4a": "修正が必要なチェックはありません。", "9e7c221b8d": "チェックの再実行に失敗しました", "e463ec935f": "リクエストされた再実行をチェックする", @@ -1689,7 +1689,7 @@ "a18d01cda3": "まだチェックは報告されていません", "3912daf310": "この PR にはまだチェックが報告されていません。", "45877f5089": "チェックが見つかりませんでした", - "85e62c5266": "失敗したチェックに対して AI Agent を開始しました。", + "85e62c5266": "失敗したチェックを AI Agent に送信しました。", "ddfd42f460": "Agent を開始する前に、プロンプトを確認してください。", "a053bdd082": "AI で失敗したチェックを修正する", "1b14d0a69c": "GitHub で開く", @@ -12077,8 +12077,8 @@ "60ed678138": "選択中" }, "ChecksPanel": { - "2ef90c9819": "失敗したチェックに対して AI Agent を開始しました。", - "a0181a8d76": "競合の解決のために AI Agent を開始しました。", + "2ef90c9819": "失敗したチェックを AI Agent に送信しました。", + "a0181a8d76": "競合の解決を AI Agent に送信しました。", "34464d00b9": "更新されました", "058039787c": "キャンセル", "2ab7fd4b6d": "保存", @@ -12432,7 +12432,7 @@ "f4c766f1ca": "この実行用の Agent とコマンドテンプレートを選択します。", "1a6a6e0bc5": "ホストされたレビューの詳細を生成する", "6b122529d4": "コミットメッセージの生成", - "e48caaf0dd": "競合の解決のために AI Agent を開始しました。", + "e48caaf0dd": "競合の解決を AI Agent に送信しました。", "901140f47d": "Agent を開始する前に、プロンプトを確認してください。", "19652ddd76": "AI で対立を解決する", "c9ad22888e": "このリポジトリのブランチ比較ターゲットを選択します。", @@ -13028,7 +13028,7 @@ "9bbd9077a2": "有効な AI Agent がありません。[設定] で Agent を構成してください。", "5540ff50cc": "Agent 起動コマンドを構築できませんでした。", "216f762bd7": "ワークスペースの接続を解決できません。", - "success": "{{value0}}の失敗に対してAIAgent を起動しました。" + "success": "{{value0}}の失敗を AI Agent に送信しました。" } } }, @@ -15379,7 +15379,7 @@ "4f2d9a8c17": "AI 操作を開始する前に、リポジトリを選択してください。", "7c3e1b5d42": "AI による修正を開始する前に、PR または MR を開いてください。", "9b2f6d4a81": "このチェックは失敗していません。", - "2ef90c9819": "このチェックに対して AI Agent を開始しました。" + "2ef90c9819": "このチェックを AI Agent に送信しました。" } } } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index d546e9443fc9..985d2e089902 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1189,7 +1189,7 @@ "f4b1292569": "이 체크에 기본 AI agent 시작", "9a1004fc76": "체크 새로고침", "03e542fcfe": "실패한 체크를 수정하기 위해 AI agent를 시작하지 못했습니다: {{value0}}", - "28986b3747": "실패한 체크를 위한 AI agent를 시작했습니다.", + "28986b3747": "실패한 체크를 AI agent에 보냈습니다.", "1690fd7f4a": "수정할 실패한 체크가 없습니다.", "9e7c221b8d": "체크를 다시 실행하지 못했습니다.", "e463ec935f": "체크 재실행 요청됨", @@ -1689,7 +1689,7 @@ "a18d01cda3": "아직 보고된 체크가 없습니다.", "3912daf310": "이 PR에는 아직 보고된 체크가 없습니다.", "45877f5089": "체크를 찾을 수 없습니다.", - "85e62c5266": "실패한 체크를 위한 AI agent를 시작했습니다.", + "85e62c5266": "실패한 체크를 AI agent에 보냈습니다.", "ddfd42f460": "agent를 시작하기 전에 프롬프트를 검토하세요.", "a053bdd082": "AI로 실패한 체크 수정", "1b14d0a69c": "GitHub에서 열기", @@ -12077,8 +12077,8 @@ "60ed678138": "선택됨" }, "ChecksPanel": { - "2ef90c9819": "실패한 검사에 대한 AI agent를 시작했습니다.", - "a0181a8d76": "충돌에 대한 AI agent를 시작했습니다.", + "2ef90c9819": "실패한 검사를 AI agent에 보냈습니다.", + "a0181a8d76": "충돌을 AI agent에 보냈습니다.", "34464d00b9": "업데이트됨", "058039787c": "취소", "2ab7fd4b6d": "저장", @@ -12432,7 +12432,7 @@ "f4c766f1ca": "이 실행을 위한 agent 및 명령 템플릿을 선택합니다.", "1a6a6e0bc5": "호스팅된 리뷰 세부 정보 생성", "6b122529d4": "Commit 메시지 생성", - "e48caaf0dd": "충돌에 대한 AI agent를 시작했습니다.", + "e48caaf0dd": "충돌을 AI agent에 보냈습니다.", "901140f47d": "agent를 시작하기 전에 전체 명령 입력을 리뷰하고 편집하세요.", "19652ddd76": "AI로 충돌 해결", "c9ad22888e": "이 repos의 브랜치 비교 대상을 선택하세요.", @@ -13028,7 +13028,7 @@ "9bbd9077a2": "활성화된 AI 에이전트가 없습니다. 설정에서 에이전트를 구성하세요.", "5540ff50cc": "에이전트 시작 명령을 구성할 수 없습니다.", "216f762bd7": "워크트리 연결을 확인할 수 없습니다.", - "success": "{{value0}} 실패에 대한 AI 에이전트가 시작되었습니다." + "success": "{{value0}} 실패를 AI 에이전트에 보냈습니다." } } }, @@ -15379,7 +15379,7 @@ "4f2d9a8c17": "AI 작업을 실행하기 전에 repos를 선택하세요.", "7c3e1b5d42": "AI 수정을 실행하기 전에 PR 또는 MR을 여세요.", "9b2f6d4a81": "이 체크는 실패 상태가 아닙니다.", - "2ef90c9819": "이 체크를 처리할 AI agent를 시작했습니다." + "2ef90c9819": "이 체크를 AI agent에 보냈습니다." } } } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 85eade8a2663..0591697f858f 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1189,7 +1189,7 @@ "f4b1292569": "在这些检查中启动默认的 AI 智能体", "9a1004fc76": "刷新检查", "03e542fcfe": "无法为失败的检查启动 AI 智能体:{{value0}}", - "28986b3747": "已启动 AI 智能体处理失败的检查。", + "28986b3747": "已发送给 AI 智能体处理失败的检查。", "1690fd7f4a": "没有需要修复的失败检查。", "9e7c221b8d": "无法重新运行检查", "e463ec935f": "检查请求重新运行", @@ -1689,7 +1689,7 @@ "a18d01cda3": "尚未报告任何 CI 检查", "3912daf310": "此PR尚未报告检查。", "45877f5089": "未找到检查", - "85e62c5266": "已启动 AI 智能体处理失败的检查。", + "85e62c5266": "已发送给 AI 智能体处理失败的检查。", "ddfd42f460": "启动智能体前请查看提示词。", "a053bdd082": "用 AI 修复失败的检查", "1b14d0a69c": "在 GitHub 中打开", @@ -12129,8 +12129,8 @@ "60ed678138": "已选择" }, "ChecksPanel": { - "2ef90c9819": "已启动 AI 智能体处理失败的检查。", - "a0181a8d76": "已启动 AI 智能体处理冲突。", + "2ef90c9819": "已发送给 AI 智能体处理失败的检查。", + "a0181a8d76": "已发送给 AI 智能体处理冲突。", "34464d00b9": "已更新", "058039787c": "取消", "2ab7fd4b6d": "保存", @@ -12484,7 +12484,7 @@ "f4c766f1ca": "为此运行选择智能体和提示词模板。", "1a6a6e0bc5": "生成托管评论详细信息", "6b122529d4": "生成提交信息", - "e48caaf0dd": "已启动 AI 智能体处理冲突。", + "e48caaf0dd": "已发送给 AI 智能体处理冲突。", "901140f47d": "启动智能体前请查看并编辑完整提示词。", "19652ddd76": "用 AI 解决冲突", "c9ad22888e": "选择此存储库的分支比较目标。", @@ -13080,7 +13080,7 @@ "9bbd9077a2": "没有已启用的 AI 智能体。请在设置中配置智能体。", "5540ff50cc": "无法构建智能体启动命令。", "216f762bd7": "无法解析工作区连接。", - "success": "已为 {{value0}} 失败启动 AI 智能体。" + "success": "已将 {{value0}} 失败发送给 AI 智能体。" } } }, @@ -15431,7 +15431,7 @@ "4f2d9a8c17": "启动 AI 操作前请选择仓库。", "7c3e1b5d42": "启动 AI 修复前请打开 PR 或 MR。", "9b2f6d4a81": "此检查没有失败。", - "2ef90c9819": "已为此检查启动 AI 智能体。" + "2ef90c9819": "已将此检查发送给 AI 智能体。" } } } From e8229acb69bd93ebbb2d4f21f722fc66cec6b305 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:37:14 -0700 Subject: [PATCH 71/84] fix(mobile): a launch whose prompt or notes weren't sent no longer says the agent started On a structured chat the launch opens the chat at rest, so "The agent started, but ..." was false. The messages now say only what is known: "The prompt wasn't sent to the agent.", "The notes weren't sent to the agent.", and "Couldn't confirm the prompt/notes were sent to the agent." --- mobile/src/components/AgentLaunchNotice.test.tsx | 10 +++++----- mobile/src/components/AgentLaunchNotice.tsx | 2 +- mobile/src/session/new-tab-agent-host-launch.ts | 10 ++++------ mobile/src/session/pr-ai-triage-launch.ts | 5 +++-- .../use-mobile-diff-review-send-actions.test.ts | 2 +- .../src/session/use-mobile-diff-review-send-actions.ts | 4 +--- mobile/src/session/use-mobile-pr-ai-triage.test.ts | 2 +- mobile/src/session/use-mobile-pr-ai-triage.ts | 2 +- ...mobile-session-terminal-create-agent-launch.test.ts | 5 +---- .../use-mobile-commit-failure-recovery.ts | 2 +- 10 files changed, 19 insertions(+), 25 deletions(-) diff --git a/mobile/src/components/AgentLaunchNotice.test.tsx b/mobile/src/components/AgentLaunchNotice.test.tsx index 0123ad1b8765..ca5e90c44b8c 100644 --- a/mobile/src/components/AgentLaunchNotice.test.tsx +++ b/mobile/src/components/AgentLaunchNotice.test.tsx @@ -63,10 +63,8 @@ describe('AgentLaunchNotice', () => { }) it('keeps a real failure in the error style beside a warning', () => { - expect( - lines({ error: "The agent started, but the prompt wasn't sent.", warning: 'w' }) - ).toEqual([ - { text: "The agent started, but the prompt wasn't sent.", isError: true }, + expect(lines({ error: "The prompt wasn't sent to the agent.", warning: 'w' })).toEqual([ + { text: "The prompt wasn't sent to the agent.", isError: true }, { text: 'w', isError: false } ]) }) @@ -85,7 +83,9 @@ describe('AgentLaunchNotice', () => { }) it('never confirms beside an error', () => { - expect(lines({ success: 'Sent to an agent', error: 'e' })).toEqual([{ text: 'e', isError: true }]) + expect(lines({ success: 'Sent to an agent', error: 'e' })).toEqual([ + { text: 'e', isError: true } + ]) }) it('renders nothing with nothing to say', () => { diff --git a/mobile/src/components/AgentLaunchNotice.tsx b/mobile/src/components/AgentLaunchNotice.tsx index 00d309477efa..9c9f0d4cce63 100644 --- a/mobile/src/components/AgentLaunchNotice.tsx +++ b/mobile/src/components/AgentLaunchNotice.tsx @@ -10,7 +10,7 @@ import { colors, spacing, typography } from '../theme/mobile-theme' type Props = { availability: MobileAgentLaunchAvailability - /** The confirmation that the agent started with its prompt. */ + /** The confirmation that the prompt was sent to an agent. */ success: string | null error: string | null /** The host's note on a launch that went ahead; secondary text, not an error. */ diff --git a/mobile/src/session/new-tab-agent-host-launch.ts b/mobile/src/session/new-tab-agent-host-launch.ts index 71326c48f78e..018d5270acae 100644 --- a/mobile/src/session/new-tab-agent-host-launch.ts +++ b/mobile/src/session/new-tab-agent-host-launch.ts @@ -16,11 +16,9 @@ import { type PendingSessionSelection } from './pending-session-selection' -const NOTES_NOT_SENT_MESSAGE = "The agent started, but the notes weren't sent." -export const PROMPT_UNCONFIRMED_MESSAGE = - "The agent started, but couldn't confirm the prompt was sent." -export const NOTES_UNCONFIRMED_MESSAGE = - "The agent started, but couldn't confirm the notes were sent." +const NOTES_NOT_SENT_MESSAGE = "The notes weren't sent to the agent." +export const PROMPT_UNCONFIRMED_MESSAGE = "Couldn't confirm the prompt was sent to the agent." +export const NOTES_UNCONFIRMED_MESSAGE = "Couldn't confirm the notes were sent to the agent." export type NewTabAgentLaunchOptions = MobileQuickCommandLaunch['options'] & { onPromptSent?: () => void @@ -80,7 +78,7 @@ export async function launchNewTabAgentThroughHost(args: { return true } if (launched.kind === 'unknown') { - // Why: a listed tab proves the agent started, so only the prompt is in doubt; notes stay unsent. + // Why: a listed tab proves the launch went through, so only the prompt is in doubt; notes stay unsent. if (isLaunchedSurfaceListed(args.getSessionTabs(), reservation)) { if (prompt?.trim()) { triggerError() diff --git a/mobile/src/session/pr-ai-triage-launch.ts b/mobile/src/session/pr-ai-triage-launch.ts index 842246f86438..cd5d3fac3526 100644 --- a/mobile/src/session/pr-ai-triage-launch.ts +++ b/mobile/src/session/pr-ai-triage-launch.ts @@ -15,14 +15,15 @@ import { resolveMobileSourceControlLaunchAgent } from './mobile-source-control-l export type MobilePromptedAgentLaunch = | { kind: 'sent'; warning?: string } - /** The agent started but `prompt`, the text as sent, did not reach it; the caller offers it. */ + /** The launch went through but `prompt`, the text as sent, did not reach the agent; the caller + * offers it. */ | { kind: 'prompt-not-sent'; prompt: string; warning?: string } /** Nothing started; `message` says why. */ | { kind: 'not-started'; message: string } /** The agent may be running; do not launch again until the user has looked. */ | { kind: 'unconfirmed'; message: string } -export const AGENT_PROMPT_NOT_SENT_MESSAGE = "The agent started, but the prompt wasn't sent." +export const AGENT_PROMPT_NOT_SENT_MESSAGE = "The prompt wasn't sent to the agent." export async function launchAgentWithPrompt(args: { client: RpcClient diff --git a/mobile/src/session/use-mobile-diff-review-send-actions.test.ts b/mobile/src/session/use-mobile-diff-review-send-actions.test.ts index 125e2e583504..c729bacd032b 100644 --- a/mobile/src/session/use-mobile-diff-review-send-actions.test.ts +++ b/mobile/src/session/use-mobile-diff-review-send-actions.test.ts @@ -353,7 +353,7 @@ describe('useMobileDiffReviewSendActions', () => { }) expect(saveCommentsAndReviewState).not.toHaveBeenCalled() expect(setActionError).toHaveBeenLastCalledWith( - "The agent started, but the notes weren't sent. Use Copy Notes to paste them." + "The notes weren't sent to the agent. Use Copy Notes to paste them." ) }) diff --git a/mobile/src/session/use-mobile-diff-review-send-actions.ts b/mobile/src/session/use-mobile-diff-review-send-actions.ts index bea344cd3874..df23c584a502 100644 --- a/mobile/src/session/use-mobile-diff-review-send-actions.ts +++ b/mobile/src/session/use-mobile-diff-review-send-actions.ts @@ -156,9 +156,7 @@ export function useMobileDiffReviewSendActions(input: SendActionsInput) { } if (result.kind === 'prompt-not-sent') { // Notes stay unsent so Copy Notes and a later send still carry them. - setActionError( - "The agent started, but the notes weren't sent. Use Copy Notes to paste them." - ) + setActionError("The notes weren't sent to the agent. Use Copy Notes to paste them.") return } await markNotesSent(comments) diff --git a/mobile/src/session/use-mobile-pr-ai-triage.test.ts b/mobile/src/session/use-mobile-pr-ai-triage.test.ts index 7e0c0d4a5a39..73c72371ccba 100644 --- a/mobile/src/session/use-mobile-pr-ai-triage.test.ts +++ b/mobile/src/session/use-mobile-pr-ai-triage.test.ts @@ -83,7 +83,7 @@ describe('useMobilePrAiTriage', () => { await triage?.launch('fix-checks', () => 'fix the failing checks') }) expect(triage?.noticeFor('fix-checks')).toMatchObject({ - error: "The agent started, but the prompt wasn't sent." + error: "The prompt wasn't sent to the agent." }) expect(triage?.noticeFor('fix-checks').undeliveredPrompt).toContain('fix the failing checks') expect(triage?.noticeFor('fix-checks').success).toBeNull() diff --git a/mobile/src/session/use-mobile-pr-ai-triage.ts b/mobile/src/session/use-mobile-pr-ai-triage.ts index 70215d9bbf02..f4893ee5d4cb 100644 --- a/mobile/src/session/use-mobile-pr-ai-triage.ts +++ b/mobile/src/session/use-mobile-pr-ai-triage.ts @@ -24,7 +24,7 @@ export type PrAiTriageLaunchNotice = { success: string | null error: string | null warning: string | null - /** The agent started without its prompt; kept so the user can paste it in themselves. */ + /** The launch went through without its prompt; kept so the user can paste it in themselves. */ undeliveredPrompt: string | null } diff --git a/mobile/src/session/use-mobile-session-terminal-create-agent-launch.test.ts b/mobile/src/session/use-mobile-session-terminal-create-agent-launch.test.ts index 129bf1edf9b9..39035729e679 100644 --- a/mobile/src/session/use-mobile-session-terminal-create-agent-launch.test.ts +++ b/mobile/src/session/use-mobile-session-terminal-create-agent-launch.test.ts @@ -517,10 +517,7 @@ describe('launches that carry a prompt', () => { await create_(state, 'codex', { initialPrompt: 'the notes', onPromptSent }) expect(onPromptSent).not.toHaveBeenCalled() - expect(state.showToast).toHaveBeenCalledWith( - "The agent started, but the notes weren't sent.", - 2400 - ) + expect(state.showToast).toHaveBeenCalledWith("The notes weren't sent to the agent.", 2400) }) it('says a quick command prompt was not sent', async () => { diff --git a/mobile/src/source-control/use-mobile-commit-failure-recovery.ts b/mobile/src/source-control/use-mobile-commit-failure-recovery.ts index fb19f7495ee2..57aa73b3e100 100644 --- a/mobile/src/source-control/use-mobile-commit-failure-recovery.ts +++ b/mobile/src/source-control/use-mobile-commit-failure-recovery.ts @@ -35,7 +35,7 @@ export function useMobileCommitFailureRecovery({ // would start a second agent. const inFlightRef = useRef(false) const [launchError, setLaunchError] = useState(null) - // The agent started without its prompt; kept so the user can paste it in themselves. Keyed by the + // The launch went through without its prompt; kept so the user can paste it in themselves. Keyed by the // failure it was built for, so a new failure never shows the previous one's prompt. const [undelivered, setUndelivered] = useState<{ failure: MobileCommitFailureRecovery From 66d7862b7f30cd3a8d86444801d5040fbd34dc41 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:37:15 -0700 Subject: [PATCH 72/84] docs(checks-panel): onLaunched fires once the prompt is delivered, which for a chat is when it records the message --- .../right-sidebar/runSourceControlAgentActionStart.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts index f160d84e4db3..fee9d32b8222 100644 --- a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts @@ -48,7 +48,8 @@ type RunSourceControlAgentActionStartArgs = { * Fires as soon as the agent tab/session is created, before deferred * submit-after-ready prompt delivery finishes. Reversible bookkeeping only — * irreversible side effects (posting host replies, resolving threads) belong in - * onLaunched, which only fires once the prompt actually reached the agent. + * onLaunched, which fires once the prompt is delivered: a terminal's paste landed, or a + * structured chat recorded its message (whose agent may still be starting). */ onLaunchAccepted?: () => void /** Fires when a launch that already reported onLaunchAccepted failed to deliver its prompt. */ From f5138310636b0ae130e79b49a61294eb2df01d52 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:38:54 -0700 Subject: [PATCH 73/84] test(cross-version): the agent-session suite's runtime stub and call helper in their own module The suite went over its line limit with the review-reply case; the two helpers move unchanged. --- .../agent-session-wire-calls.ts | 55 +++++++++++++++++++ ...ss-version-agent-session-wire.unit.test.ts | 47 +--------------- 2 files changed, 56 insertions(+), 46 deletions(-) create mode 100644 tests/e2e/cross-version-wire/agent-session-wire-calls.ts diff --git a/tests/e2e/cross-version-wire/agent-session-wire-calls.ts b/tests/e2e/cross-version-wire/agent-session-wire-calls.ts new file mode 100644 index 000000000000..791759253b60 --- /dev/null +++ b/tests/e2e/cross-version-wire/agent-session-wire-calls.ts @@ -0,0 +1,55 @@ +// How the cross-version suites call one build's agent-session dispatcher: a runtime stub that has +// what the structured methods read, and every reply a call produced. + +import { RuntimeSubscriptionRegistry } from '../../../src/main/runtime/runtime-subscription-registry' +import { attachParams } from './structured-agent-session-surface-manifest' +import type { + AgentSessionWireBuild, + RpcClientIdentity, + RpcReply +} from './versioned-agent-session-wire' + +export function runtimeStub(overrides: Record = {}): unknown { + const subscriptions = new RuntimeSubscriptionRegistry() + return { + getRuntimeId: () => 'runtime-1', + getClientSettings: () => ({ experimentalStructuredNativeChat: true }), + ensureStructuredAgentSessionHost: async () => undefined, + getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), + structuredAgentSessionLaunchSeedOptions: () => undefined, + resolveStructuredAgentSessionCreateIntent: async () => { + const { + envelope: _envelope, + providerHandle: _providerHandle, + ...resolved + } = attachParams(null) + return resolved + }, + publishStructuredAgentSessionTab: () => {}, + registerSubscriptionCleanup: subscriptions.register.bind(subscriptions), + registerOwnedSubscriptionCleanup: subscriptions.registerOwned.bind(subscriptions), + cleanupSubscription: subscriptions.cleanup.bind(subscriptions), + cleanupSubscriptionsByPrefix: subscriptions.cleanupByPrefix.bind(subscriptions), + ...overrides + } +} + +/** Every reply one call produced. Streaming methods answer more than once, and a + * refusal has to arrive as a reply rather than as silence. */ +export async function callBuild( + build: AgentSessionWireBuild, + method: string, + params: unknown, + client: RpcClientIdentity, + runtime: unknown = runtimeStub() +): Promise { + const replies: RpcReply[] = [] + await build + .createDispatcher(runtime) + .dispatchStreaming( + { id: `request-${method}`, authToken: 'cross-version-token', method, params }, + (raw) => replies.push(JSON.parse(raw) as RpcReply), + client + ) + return replies +} diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index e5654b585f9f..f072b3f00fae 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -21,7 +21,6 @@ import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/age import { startAgentForTests } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-support' import type { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store-test-harness' -import { RuntimeSubscriptionRegistry } from '../../../src/main/runtime/runtime-subscription-registry' import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' import { AGENT_SESSION_ACCEPTED_SEND_RUNTIME_CAPABILITY, @@ -36,6 +35,7 @@ import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../src/shared/protocol-version' import { resolveBaselineReleaseRef } from './release-checkout' +import { callBuild, runtimeStub } from './agent-session-wire-calls' import { installableHost, structuredHostStub, @@ -82,31 +82,6 @@ beforeAll(async () => { baseline = await loadAgentSessionWireBuild(baselineRef) }, SUITE_TIMEOUT_MS) -function runtimeStub(overrides: Record = {}): unknown { - const subscriptions = new RuntimeSubscriptionRegistry() - return { - getRuntimeId: () => 'runtime-1', - getClientSettings: () => ({ experimentalStructuredNativeChat: true }), - ensureStructuredAgentSessionHost: async () => undefined, - getStructuredAgentSessionCreateSupport: async () => ({ supported: true }), - structuredAgentSessionLaunchSeedOptions: () => undefined, - resolveStructuredAgentSessionCreateIntent: async () => { - const { - envelope: _envelope, - providerHandle: _providerHandle, - ...resolved - } = attachParams(null) - return resolved - }, - publishStructuredAgentSessionTab: () => {}, - registerSubscriptionCleanup: subscriptions.register.bind(subscriptions), - registerOwnedSubscriptionCleanup: subscriptions.registerOwned.bind(subscriptions), - cleanupSubscription: subscriptions.cleanup.bind(subscriptions), - cleanupSubscriptionsByPrefix: subscriptions.cleanupByPrefix.bind(subscriptions), - ...overrides - } -} - /** * What a client too old to know the structured surface advertises: the baseline's * own list, minus the capability. Derived rather than assumed to be the baseline's @@ -125,26 +100,6 @@ function baselineStructuredMethods(): string[] { return baseline.methodNames.filter((name) => name.startsWith('agentSession.')) } -/** Every reply one call produced. Streaming methods answer more than once, and a - * refusal has to arrive as a reply rather than as silence. */ -async function callBuild( - build: AgentSessionWireBuild, - method: string, - params: unknown, - client: RpcClientIdentity, - runtime: unknown = runtimeStub() -): Promise { - const replies: RpcReply[] = [] - await build - .createDispatcher(runtime) - .dispatchStreaming( - { id: `request-${method}`, authToken: 'cross-version-token', method, params }, - (raw) => replies.push(JSON.parse(raw) as RpcReply), - client - ) - return replies -} - /** * The one thing this suite exists to guarantee, written once and applied per * build: every method the manifest declares is not merely registered but reaches From c21462020c20b3f29ef7f154ed87171c3058f061 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:42:40 -0700 Subject: [PATCH 74/84] fix(checks-panel): a failed create hands the comments back and leaves the kept prompt without its review reply R5R1 refinement: a create that failed still hands the comments back to the panel, as before, so the prompt the chat keeps for a relaunch drops its review reply; only one owner can write it. A prompt the host refused or never answered after the chat existed is still held by the chat, and three existing tests now expect the delivery result to say so. --- ...ctured-agent-session-outbox-fence.test.tsx | 2 +- ...launch-agent-structured-chat-guard.test.ts | 4 ++- ...ctured-agent-session-launch-prompt.test.ts | 33 +++++++++++++++---- .../structured-agent-session-launch-prompt.ts | 8 +++-- 4 files changed, 35 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-fence.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-fence.test.tsx index 9545c083fa76..6a744ae1e053 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-fence.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-fence.test.tsx @@ -144,7 +144,7 @@ describe('an outbox on a host that accepts a send before any agent has it', () = }) ) setItem.mockRestore() - expect(delivery).toEqual({ delivered: false, failureNotified: false }) + expect(delivery).toEqual({ delivered: false, failureNotified: false, heldByChat: true }) expect(mocks.call).not.toHaveBeenCalled() expect(result.current.outbox).toMatchObject([{ state: 'queued' }]) diff --git a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts index 25f66d4af23d..ac33e09bdccf 100644 --- a/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts +++ b/src/renderer/src/lib/launch-agent-structured-chat-guard.test.ts @@ -578,9 +578,11 @@ describe('structured chat adoption guard on the launch path', () => { promptDelivery: 'submit-after-ready' }) + // The chat keeps it for that retry, so it says it holds it. await expect(result?.promptDeliveryResult).resolves.toEqual({ delivered: false, - failureNotified: false + failureNotified: false, + heldByChat: true }) expect(mockCreateTab).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts index f2b9206e571d..6855b331b98c 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts @@ -3,7 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { enqueueStructuredAgentSessionLaunchPrompt, - mutateStructuredAgentSessionLaunchPrompt + mutateStructuredAgentSessionLaunchPrompt, + readOutbox } from '@/components/native-chat/structured-agent-session-outbox-storage' const mocks = vi.hoisted(() => ({ call: vi.fn() })) @@ -117,14 +118,32 @@ describe('a launch prompt the host did not record', () => { failureNotified: false, heldByChat: true }) - // A failed create that kept the outbox for its relaunch still holds it. - await expect(settle(Promise.reject(new Error('create failed')))).resolves.toMatchObject({ - heldByChat: true + mutateStructuredAgentSessionLaunchPrompt('session-1', stagedEntry!.clientMessageId, () => null) + await expect(settle(Promise.resolve({ sessionId: 'session-1', fence: 1 }))).resolves.toEqual({ + delivered: false, + failureNotified: false }) + }) - mutateStructuredAgentSessionLaunchPrompt('session-1', stagedEntry!.clientMessageId, () => null) - await expect(settle(Promise.reject(new Error('create failed')))).rejects.toThrow( - 'create failed' + it('keeps no review reply on a prompt whose create failed, as the source takes its comments back', async () => { + const reviewReply = { provider: 'gitlab' as const, repoId: 'repo-1', iid: 8, resolve: ['d-1'] } + const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt( + 'session-1', + 'review this', + reviewReply ) + + await expect( + settleStructuredAgentLaunchPrompt({ + launchResult: Promise.reject(new Error('create failed')), + target: { kind: 'local' }, + options: { prompt: 'review this' }, + stagedEntry + }) + ).rejects.toThrow('create failed') + + const [kept] = readOutbox('session-1') + expect(kept).toMatchObject({ clientMessageId: stagedEntry!.clientMessageId }) + expect(kept).not.toHaveProperty('reviewReply') }) }) diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index 71c47e9f7f91..154679639d06 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -201,10 +201,12 @@ export function settleStructuredAgentLaunchPrompt(args: { } return chatHoldsLaunchPrompt(entry) ? held() : { delivered, failureNotified: false } }) - // A create that failed but kept the chat's outbox for its relaunch still owns the prompt. + // A create that failed hands the source back its comments, so a relaunch of the prompt the chat + // kept must not carry their writes too. return settled.catch((error: unknown) => { - if (chatHoldsLaunchPrompt(args.stagedEntry)) { - return held() + const entry = args.stagedEntry + if (entry?.reviewReply) { + mutateEntry(entry, ({ reviewReply: _handedBack, ...kept }) => kept) } throw error }) From 8ea74b1aee6a54cf8e64a23235d637f5fe197ee9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:57:06 -0700 Subject: [PATCH 75/84] fix(checks-panel): the panel writes the review reply unless a structured chat's message took it The panel decided the reply rode the message whenever the launch took the structured route. A paired host that declines the chat opens the same launch as a terminal, whose paste carries no reply, so nobody resolved the threads or posted. The launch prompt's delivery result now says whether the chat's message carries the reply, and the panel writes after delivery otherwise. --- .../comment-resolution-review-reply.test.tsx | 30 ++++++++++-- .../runSourceControlAgentActionStart.ts | 4 +- ...ctured-agent-session-launch-prompt.test.ts | 48 +++++++++++++++++++ .../structured-agent-session-launch-prompt.ts | 9 +++- 4 files changed, 85 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx index 81e4fc94cfc0..5c051cf24a66 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx @@ -128,6 +128,11 @@ function resolveCommentsWithAi(hook: ReturnType['hook']) const onClose = vi.fn() const DELIVERED = Promise.resolve({ delivered: true, failureNotified: false }) +const CARRIED = Promise.resolve({ + delivered: true, + failureNotified: false, + reviewReplyCarried: true as const +}) describe('Resolve comments with AI', () => { beforeEach(() => { @@ -142,7 +147,7 @@ describe('Resolve comments with AI', () => { const { model, hook } = acknowledgement() mocks.launchAgentInNewTab.mockReturnValue({ surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'session-1' }, - promptDeliveryResult: DELIVERED, + promptDeliveryResult: CARRIED, structuredSettlement: Promise.resolve({ kind: 'launched' }) }) @@ -175,7 +180,8 @@ describe('Resolve comments with AI', () => { promptDeliveryResult: Promise.resolve({ delivered: false, failureNotified: false, - heldByChat: true + heldByChat: true, + reviewReplyCarried: true }), structuredSettlement: Promise.resolve({ kind: 'launched' }) }) @@ -212,7 +218,7 @@ describe('Resolve comments with AI', () => { const { model, hook } = acknowledgement(withoutReplyTarget) mocks.launchAgentInNewTab.mockReturnValue({ surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'session-1' }, - promptDeliveryResult: DELIVERED, + promptDeliveryResult: CARRIED, structuredSettlement: Promise.resolve({ kind: 'launched' }) }) @@ -247,6 +253,24 @@ describe('Resolve comments with AI', () => { expect(model.addPRConversationComment).toHaveBeenCalledOnce() }) + it('writes once from the panel when a paired host declined the chat and its terminal took the paste', async () => { + const { model, hook } = acknowledgement() + // The structured route was taken, but the host opened a terminal, whose paste carried no reply. + mocks.launchAgentInNewTab.mockReturnValue({ + surface: { kind: 'host-published' }, + promptDeliveryResult: DELIVERED, + structuredSettlement: Promise.resolve({ kind: 'terminal' }) + }) + + await expect(resolveCommentsWithAi(hook)).resolves.toBe(true) + + expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith( + expect.objectContaining({ reviewReply: expect.anything() }) + ) + await vi.waitFor(() => expect(model.resolveReviewThread).toHaveBeenCalledOnce()) + expect(model.addPRConversationComment).toHaveBeenCalledOnce() + }) + it('writes once from the panel when a terminal agent took the paste', async () => { const { model, hook } = acknowledgement() mocks.launchAgentInNewTab.mockReturnValue({ diff --git a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts index fee9d32b8222..21b42b6144fb 100644 --- a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts @@ -128,7 +128,6 @@ export async function runSourceControlAgentActionStart({ ...(plannedReviewReply ? { reviewReply: plannedReviewReply } : {}) }) launched = Boolean(result) - reviewReplyCarried = Boolean(plannedReviewReply && result?.structuredSettlement) if (result?.surface.kind === 'local-terminal') { focusTerminalTabSurface(result.surface.tabId) } @@ -140,6 +139,9 @@ export async function runSourceControlAgentActionStart({ if (result?.promptDeliveryResult) { try { const deliveryResult = await result.promptDeliveryResult + // Read off what took the prompt: a paired host that declined the chat opens a terminal, + // which carries no review reply, so the panel writes after its paste. + reviewReplyCarried = deliveryResult.reviewReplyCarried === true // A chat that holds its review reply sends it again itself; offering Start again here // would launch a second chat, and post the replies twice. launched = diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts index 6855b331b98c..ea8753f9f92d 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.test.ts @@ -125,6 +125,54 @@ describe('a launch prompt the host did not record', () => { }) }) + it('says the message carries the review reply only when the chat took a prompt that had one', async () => { + const reviewReply = { provider: 'gitlab' as const, repoId: 'repo-1', iid: 8, resolve: ['d-1'] } + const withReply = enqueueStructuredAgentSessionLaunchPrompt('session-1', 'fix', reviewReply) + const plain = enqueueStructuredAgentSessionLaunchPrompt('session-2', 'fix') + mocks.call.mockImplementation( + async ( + _target: unknown, + _method: string, + request: { envelope: { clientOperationId: string } } + ) => ({ + ok: true, + replayed: false, + fence: 1, + cursor: { epoch: 'epoch-1', sequence: 1 }, + value: { + clientMessageId: request.envelope.clientOperationId, + submission: { + clientMessageId: request.envelope.clientOperationId, + fence: 1, + payloadFingerprint: 'fingerprint', + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null + } + } + }) + ) + const settle = (stagedEntry: typeof plain, sessionId: string) => + settleStructuredAgentLaunchPrompt({ + launchResult: Promise.resolve({ sessionId, fence: 1 }), + target: { kind: 'local' }, + options: { prompt: 'fix' }, + stagedEntry + }) + + await expect(settle(withReply, 'session-1')).resolves.toEqual({ + delivered: true, + failureNotified: false, + reviewReplyCarried: true + }) + await expect(settle(plain, 'session-2')).resolves.toEqual({ + delivered: true, + failureNotified: false + }) + }) + it('keeps no review reply on a prompt whose create failed, as the source takes its comments back', async () => { const reviewReply = { provider: 'gitlab' as const, repoId: 'repo-1', iid: 8, resolve: ['d-1'] } const stagedEntry = enqueueStructuredAgentSessionLaunchPrompt( diff --git a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts index 154679639d06..aea6acb84351 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-prompt.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-prompt.ts @@ -25,6 +25,9 @@ export type StructuredPromptDeliveryResult = { /** Not delivered, but its chat still holds it and sends it again or offers its Retry: the chat * owns it from here, so the caller does not offer it again. */ heldByChat?: true + /** The chat's message carries the launch's review reply, which its host writes; set only by a + * structured chat that took the prompt, never by a terminal it fell back to. */ + reviewReplyCarried?: true } /** Whether the chat still holds this launch prompt, under its first id or one a refusal rotated. */ @@ -178,10 +181,12 @@ export function settleStructuredAgentLaunchPrompt(args: { if (args.options.promptDelivery === 'draft' || !args.options.prompt?.trim()) { return undefined } + const carried = args.stagedEntry?.reviewReply ? { reviewReplyCarried: true as const } : {} const held = (): StructuredPromptDeliveryResult => ({ delivered: false, failureNotified: false, - heldByChat: true + heldByChat: true, + ...carried }) const settled = args.launchResult.then(async (receipt) => { if (!args.stagedEntry) { @@ -197,7 +202,7 @@ export function settleStructuredAgentLaunchPrompt(args: { const delivered = await dispatch.promise if (delivered) { args.options.onPromptDelivered?.() - return { delivered, failureNotified: false } + return { delivered, failureNotified: false, ...carried } } return chatHoldsLaunchPrompt(entry) ? held() : { delivered, failureNotified: false } }) From 562d5884350603adfae5b6b13d7238f1dd09687f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:57:06 -0700 Subject: [PATCH 76/84] docs(native-chat): a refused review-reply receipt is derived again only after a restart; only a failed read is logged --- .../structured-agent-session-review-replies.ts | 3 ++- .../structured-agent-session-review-reply-runner.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts index 9b6e1e8c9ab5..403556a86f44 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts @@ -112,7 +112,8 @@ export class StructuredAgentSessionReviewReplies { this.deps.log('review reply: the run failed', error) failure = error instanceof Error ? error.message : String(error) } - // A receipt the chat refuses (closed, read-only) is re-derived on its next open in the window. + // A receipt the chat refuses (closed, read-only) is logged. This process keeps its claim, so the + // reply is derived again only after Orca restarts, inside the window, re-reading the PR first. await this.deps .writeReceipt(sessionId, clientMessageId, failure) .catch((error: unknown) => this.deps.log('review reply: the receipt was not written', error)) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-review-reply-runner.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-review-reply-runner.ts index f907184f2c43..2697c4accef7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-review-reply-runner.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-review-reply-runner.ts @@ -78,7 +78,8 @@ async function gitHubWrites( } ): Promise { const posted = options.reread ? await postedSince(runtime, repo, spec, options) : null - // A reply with no thread to read, or no read at all, is posted: at worst once more, reported. + // A reply with no thread to read, or with no read at all, is posted: at worst once more. Only a + // failed read is logged; a reply with no thread id is rare (path-only review comments). const alreadyPosted = (body: string, at: { threadId?: string } | null): boolean => { if (!posted) { return false From 532794e4c9d2186023e0d394a0f70ceb28e5db3d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:05:41 -0700 Subject: [PATCH 77/84] fix(checks-panel): the panel refetches the PR once a structured chat's host has written its review reply On a paired server the host's mutation notice never reaches this client, so the checks panel kept showing the threads open with no "Fixing" reply. When a launch hands its review reply to a chat, the panel now watches that chat's live stream for the message's receipt (a failure line or a tombstone) and refetches once. The watch is one-shot and dies with the panel, when the chat closes, or after the 24 h window in which the host still owes the reply. No new notice or toast. --- ...structured-agent-session-review-replies.ts | 15 ++-- ...ession-read-transport-review-reply.test.ts | 61 ++++++++++++++ ...structured-agent-session-read-transport.ts | 2 + .../SourceControlAgentActionDialog.tsx | 3 +- .../comment-resolution-review-reply.test.tsx | 53 +++++++++++++ .../use-checks-panel-ai-acknowledgement.tsx | 27 ++++++- .../runSourceControlAgentActionStart.ts | 28 ++++++- .../useSourceControlAgentActionStart.ts | 3 +- ...agent-session-review-reply-settled.test.ts | 79 +++++++++++++++++++ ...ured-agent-session-review-reply-settled.ts | 75 ++++++++++++++++++ ...tured-agent-session-tab-retirement.test.ts | 6 ++ ...structured-agent-session-tab-retirement.ts | 2 + src/shared/agent-session-review-reply.ts | 20 +++++ 13 files changed, 363 insertions(+), 11 deletions(-) create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-read-transport-review-reply.test.ts create mode 100644 src/renderer/src/lib/structured-agent-session-review-reply-settled.test.ts create mode 100644 src/renderer/src/lib/structured-agent-session-review-reply-settled.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts index 403556a86f44..002515034fc1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts @@ -3,7 +3,11 @@ // receipt for it. The receipt is an item in the chat under the message's own id: a status line when // a write failed, a tombstone (nothing shown) when all went through. -import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' +import { + AGENT_SESSION_REVIEW_REPLY_WINDOW_MS, + agentSessionReviewReplyReceiptMessageId, + type AgentSessionReviewReply +} from '../../../shared/agent-session-review-reply' import { agentSessionFailureFact, providerDiagnostic } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' @@ -13,12 +17,13 @@ import type { StructuredAgentSessionHostDeps } from './structured-agent-session- import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' import { runStructuredAgentSessionReviewReply } from './structured-agent-session-review-reply-runner' -/** A message accepted longer ago than this is never re-derived: a crash's leftover must not post - * days later on some reopen. Measured from acceptance, so a late Retry still posts. */ -export const REVIEW_REPLY_WINDOW_MS = 24 * 60 * 60 * 1000 +export const REVIEW_REPLY_WINDOW_MS = AGENT_SESSION_REVIEW_REPLY_WINDOW_MS export function reviewReplyReceiptIdentity(clientMessageId: string): AgentJournalItemIdentity { - return { provider: 'orca', clientMessageId: `${clientMessageId}#review-reply` } + return { + provider: 'orca', + clientMessageId: agentSessionReviewReplyReceiptMessageId(clientMessageId) + } } /** The receipt: the failure said once on its own line, or a tombstone that shows nothing. */ diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport-review-reply.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport-review-reply.test.ts new file mode 100644 index 000000000000..2450a7e49990 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport-review-reply.test.ts @@ -0,0 +1,61 @@ +import { afterEach, expect, it, vi } from 'vitest' +import type { AgentSessionSubscribeEvent } from '../../../../shared/agent-session-wire' + +const mocks = vi.hoisted(() => ({ subscribe: vi.fn(), watchHostContact: vi.fn() })) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + subscribeStructuredAgentSession: mocks.subscribe +})) +vi.mock('@/runtime/runtime-host-contact-regained', () => ({ + subscribeRuntimeHostContactRegained: mocks.watchHostContact +})) + +import { agentJournalItemKey } from '../../../../shared/agent-session-journal-item-key' +import { agentSessionReviewReplyReceiptMessageId } from '../../../../shared/agent-session-review-reply' +import { watchStructuredReviewReplySettled } from '@/lib/structured-agent-session-review-reply-settled' +import { startStructuredAgentSessionReadTransport } from './structured-agent-session-read-transport' + +afterEach(() => { + vi.useRealTimers() +}) + +it("tells a review reply's watcher when the chat's live stream brings its receipt", async () => { + vi.useFakeTimers() + let onEvent: (event: AgentSessionSubscribeEvent) => void = () => {} + mocks.subscribe.mockImplementation((_target, _params, listener) => { + onEvent = listener + return new Promise(() => {}) + }) + const settled = vi.fn() + const dispose = watchStructuredReviewReplySettled('session-a', settled) + const transport = startStructuredAgentSessionReadTransport({ + applyEvent: () => {}, + applyError: vi.fn(), + getCursor: () => null, + onHistoryReadInvalidated: () => undefined, + sessionId: 'session-a', + target: { kind: 'local' } + }) + await vi.advanceTimersByTimeAsync(0) + + onEvent({ + type: 'batch', + sessionId: 'session-a', + batch: { + cursor: { epoch: 'epoch-a', sequence: 3 }, + items: [], + removedItemIds: [ + agentJournalItemKey({ + provider: 'orca', + clientMessageId: agentSessionReviewReplyReceiptMessageId('message-1') + }) + ], + submissions: [] + } + }) + await vi.advanceTimersByTimeAsync(100) + + expect(settled).toHaveBeenCalledOnce() + transport.dispose() + dispose() +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts index a0a693e92a9c..0186266ee013 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts @@ -1,3 +1,4 @@ +import { noticeStructuredReviewReplyReceipt } from '@/lib/structured-agent-session-review-reply-settled' import type { AgentJournalCursor } from '../../../../shared/agent-session-journal-types' import type { AgentSessionSubscribeEvent } from '../../../../shared/agent-session-wire' import { createStructuredAgentSessionEventCoalescer } from '../../../../shared/structured-agent-session-coalescer' @@ -103,6 +104,7 @@ export function startStructuredAgentSessionReadTransport(args: { const coalescer = createStructuredAgentSessionEventCoalescer((event) => { if (!shouldStopCoalescedEvent()) { args.applyEvent(event) + noticeStructuredReviewReplyReceipt(args.sessionId, event) } }) const reconnectScheduler = createReconnectScheduler({ diff --git a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx index 6c97540fc0e5..7178be3bddb0 100644 --- a/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControlAgentActionDialog.tsx @@ -1,3 +1,4 @@ +import type { SourceControlAgentLaunched } from './runSourceControlAgentActionStart' import type { AgentSessionReviewReply } from '../../../../shared/agent-session-review-reply' import React from 'react' import { @@ -49,7 +50,7 @@ export type SourceControlAgentActionDialogProps = { onLaunchAborted?: () => void /** `reviewReplyCarried`: a structured chat's message carries `reviewReply`, which its host writes * once the agent takes the message; the caller writes nothing to the review itself. */ - onLaunched?: (launch: { reviewReplyCarried: boolean }) => void + onLaunched?: (launch: SourceControlAgentLaunched) => void /** Planned at start, for a structured chat's launch prompt to carry. */ reviewReply?: () => AgentSessionReviewReply | undefined startLabel?: string diff --git a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx index 5c051cf24a66..33ee43072f70 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx @@ -29,6 +29,9 @@ import { } from '../pr-comments-ai-launch-ack' import { runSourceControlAgentActionStart } from '../runSourceControlAgentActionStart' import { useChecksPanelAiAcknowledgement } from './use-checks-panel-ai-acknowledgement' +import { agentJournalItemKey } from '../../../../../shared/agent-session-journal-item-key' +import { agentSessionReviewReplyReceiptMessageId } from '../../../../../shared/agent-session-review-reply' +import { noticeStructuredReviewReplyReceipt } from '@/lib/structured-agent-session-review-reply-settled' const REVIEW_KEY = 'repo-1::42::sha-1' @@ -173,6 +176,56 @@ describe('Resolve comments with AI', () => { expect(model.pendingCommentResolutionRef.current).toBeNull() }) + /** The chat's live stream bringing its review-reply receipt (a tombstone: all went through). */ + function receiptArrives(sessionId: string): void { + noticeStructuredReviewReplyReceipt(sessionId, { + type: 'batch', + sessionId, + batch: { + cursor: { epoch: 'e', sequence: 9 }, + items: [], + removedItemIds: [ + agentJournalItemKey({ + provider: 'orca', + clientMessageId: agentSessionReviewReplyReceiptMessageId('message-1') + }) + ], + submissions: [] + } + }) + } + + it('refetches the PR once the chat says its host wrote, on this host or a paired one', async () => { + const { model, hook } = acknowledgement() + mocks.launchAgentInNewTab.mockReturnValue({ + surface: { kind: 'host-published' }, + promptDeliveryResult: CARRIED, + structuredSettlement: Promise.resolve({ kind: 'structured', sessionId: 'paired-session' }) + }) + await expect(resolveCommentsWithAi(hook)).resolves.toBe(true) + expect(model.fetchComments).not.toHaveBeenCalled() + + receiptArrives('paired-session') + receiptArrives('paired-session') + + expect(model.fetchComments).toHaveBeenCalledExactlyOnceWith({ force: true }) + }) + + it('stops waiting for the receipt when the panel goes away', async () => { + const { model, hook } = acknowledgement() + mocks.launchAgentInNewTab.mockReturnValue({ + surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'session-1' }, + promptDeliveryResult: CARRIED, + structuredSettlement: Promise.resolve({ kind: 'structured', sessionId: 'session-1' }) + }) + await expect(resolveCommentsWithAi(hook)).resolves.toBe(true) + + hook.unmount() + receiptArrives('session-1') + + expect(model.fetchComments).not.toHaveBeenCalled() + }) + it('hands the comments to a chat that still holds its unrecorded prompt, and offers no second Start', async () => { const { model, hook } = acknowledgement() mocks.launchAgentInNewTab.mockReturnValue({ diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx index e77c349ad64f..7825388682d9 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx @@ -13,6 +13,8 @@ import { takePendingPRCommentAiAck } from '../pr-comments-ai-launch-ack' import { buildPRCommentReviewReply } from '../pr-comment-review-reply-spec' +import type { SourceControlAgentLaunched } from '../runSourceControlAgentActionStart' +import { watchStructuredReviewReplySettled } from '@/lib/structured-agent-session-review-reply-settled' import type { AgentSessionReviewReply } from '../../../../../shared/agent-session-review-reply' import type { ChecksPanelControllerState } from './use-checks-panel-controller-state' import type { ChecksPanelReviewDataState } from './use-checks-panel-review-data' @@ -307,6 +309,18 @@ export function useChecksPanelAiAcknowledgement(model: ChecksPanelAiAcknowledgem commentResolutionLaunchAcceptedRef ]) + // Each hand-off's refetch watch; they die with the panel. + const settledWatches = useRef(new Set<() => void>()) + useEffect(() => { + const watches = settledWatches.current + return () => { + for (const dispose of watches) { + dispose() + } + watches.clear() + } + }, []) + /** What a structured chat's launch prompt carries for its host to write once the agent takes it. */ const buildLaunchReviewReply = useCallback((): AgentSessionReviewReply | undefined => { const pending = peekPendingPRCommentAiAck() ?? pendingCommentResolutionRef.current @@ -316,7 +330,7 @@ export function useChecksPanelAiAcknowledgement(model: ChecksPanelAiAcknowledgem /** Prompt reached the agent: only now may Orca write to the host. A structured chat's message * carries the writes for its host instead, so here they are only handed off. */ const consumeClaimedCommentResolutionAfterDelivery = useCallback( - (launch?: { reviewReplyCarried: boolean }): void => { + (launch?: SourceControlAgentLaunched): void => { const resolution = claimedCommentResolutionRef.current ?? takePendingPRCommentAiAck() ?? @@ -331,6 +345,16 @@ export function useChecksPanelAiAcknowledgement(model: ChecksPanelAiAcknowledgem if (launch?.reviewReplyCarried) { clearSentCommentSelection(resolution.reviewContextKey) setCommentResolutionAckBusyNow(false) + // The chat's host may be a paired server, whose own mutation notice never reaches this + // client: refetch once the chat says its host wrote. + if (launch.sessionId) { + const provider = resolution.provider + const dispose = watchStructuredReviewReplySettled(launch.sessionId, () => { + settledWatches.current.delete(dispose) + void refreshCommentsAfterBulkResolve(provider) + }) + settledWatches.current.add(dispose) + } // The one write the chat's host can't make: without the PR's repository it can't reply. if ( resolution.provider === 'github' && @@ -356,6 +380,7 @@ export function useChecksPanelAiAcknowledgement(model: ChecksPanelAiAcknowledgem }, [ clearSentCommentSelection, + refreshCommentsAfterBulkResolve, resolveSelectedThreadsAfterLaunch, setCommentResolutionAckBusyNow, claimedCommentResolutionRef, diff --git a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts index 21b42b6144fb..f8c15cec0ceb 100644 --- a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts @@ -55,14 +55,29 @@ type RunSourceControlAgentActionStartArgs = { /** Fires when a launch that already reported onLaunchAccepted failed to deliver its prompt. */ onLaunchAborted?: () => void /** `reviewReplyCarried`: a structured chat's message carries the review reply, which its host - * writes once the agent takes it, so the caller writes nothing to the review itself. */ - onLaunched?: (launch: { reviewReplyCarried: boolean }) => void + * writes once the agent takes it, so the caller writes nothing to the review itself. + * `sessionId` names that chat, when known, so the caller can refresh once its host wrote. */ + onLaunched?: (launch: SourceControlAgentLaunched) => void /** Planned at start: what a structured chat's host writes on the review once its agent takes * the prompt. A terminal agent has no message to carry it. */ reviewReply?: () => AgentSessionReviewReply | undefined onClose: () => void } +export type SourceControlAgentLaunched = { reviewReplyCarried: boolean; sessionId?: string } + +/** The chat a structured launch opened: this client's own from its surface, a paired host's from + * the settlement that learned it. */ +async function structuredChatSessionId( + result: NonNullable> +): Promise { + if (result.surface.kind === 'local-agent-session') { + return result.surface.sessionId + } + const settled = await result.structuredSettlement?.catch(() => undefined) + return settled?.kind === 'structured' ? settled.sessionId : undefined +} + export async function runSourceControlAgentActionStart({ selectedAgent, trimmedCommandInput, @@ -89,6 +104,7 @@ export async function runSourceControlAgentActionStart({ }: RunSourceControlAgentActionStartArgs): Promise { let launched = false let reviewReplyCarried = false + let reviewReplySessionId: string | undefined let launchFailureNotified = false let launchAcceptedNotified = false // Why: `undefined` is what makes the launch fall back to the global Agents arguments; @@ -142,6 +158,9 @@ export async function runSourceControlAgentActionStart({ // Read off what took the prompt: a paired host that declined the chat opens a terminal, // which carries no review reply, so the panel writes after its paste. reviewReplyCarried = deliveryResult.reviewReplyCarried === true + if (reviewReplyCarried) { + reviewReplySessionId = await structuredChatSessionId(result) + } // A chat that holds its review reply sends it again itself; offering Start again here // would launch a second chat, and post the replies twice. launched = @@ -193,7 +212,10 @@ export async function runSourceControlAgentActionStart({ console.error('onSaveAgentDefault failed', error) } } - onLaunched?.({ reviewReplyCarried }) + onLaunched?.({ + reviewReplyCarried, + ...(reviewReplySessionId ? { sessionId: reviewReplySessionId } : {}) + }) onClose() return true } diff --git a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts index c23dfcf45267..aeef39a74915 100644 --- a/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/useSourceControlAgentActionStart.ts @@ -1,3 +1,4 @@ +import type { SourceControlAgentLaunched } from './runSourceControlAgentActionStart' import type { AgentSessionReviewReply } from '../../../../shared/agent-session-review-reply' import { useCallback, useRef, useState } from 'react' import type { LaunchSource } from '../../../../shared/telemetry-events' @@ -51,7 +52,7 @@ type UseSourceControlAgentActionStartArgs = { ) => void | Promise onLaunchAccepted?: () => void onLaunchAborted?: () => void - onLaunched?: (launch: { reviewReplyCarried: boolean }) => void + onLaunched?: (launch: SourceControlAgentLaunched) => void reviewReply?: () => AgentSessionReviewReply | undefined onClose: () => void } diff --git a/src/renderer/src/lib/structured-agent-session-review-reply-settled.test.ts b/src/renderer/src/lib/structured-agent-session-review-reply-settled.test.ts new file mode 100644 index 000000000000..931b52af5b3c --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-review-reply-settled.test.ts @@ -0,0 +1,79 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { agentSessionReviewReplyReceiptMessageId } from '../../../shared/agent-session-review-reply' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { + dropStructuredReviewReplyWatchers, + noticeStructuredReviewReplyReceipt, + watchStructuredReviewReplySettled +} from './structured-agent-session-review-reply-settled' + +const RECEIPT = agentJournalItemKey({ + provider: 'orca', + clientMessageId: agentSessionReviewReplyReceiptMessageId('message-1') +}) +const OTHER = agentJournalItemKey({ provider: 'orca', clientMessageId: 'message-1#delivery' }) + +function batch(change: { removed?: string[]; items?: string[] }): AgentSessionSubscribeEvent { + return { + type: 'batch', + sessionId: 'session-1', + batch: { + cursor: { epoch: 'e', sequence: 2 }, + items: (change.items ?? []).map((itemId) => ({ + itemId, + revision: 1, + sequence: 2, + observedAt: 1, + body: { kind: 'status' as const, text: 'x' } + })), + removedItemIds: change.removed ?? [], + submissions: [] + } + } +} + +afterEach(() => { + dropStructuredReviewReplyWatchers('session-1') + vi.useRealTimers() +}) + +describe("a source's watch for its chat's review-reply receipt", () => { + it('runs once, on a written line or a tombstone in a live batch, and only for that chat', () => { + const settled = vi.fn() + watchStructuredReviewReplySettled('session-1', settled) + + noticeStructuredReviewReplyReceipt('session-2', batch({ removed: [RECEIPT] })) + noticeStructuredReviewReplyReceipt('session-1', batch({ removed: [OTHER] })) + expect(settled).not.toHaveBeenCalled() + + noticeStructuredReviewReplyReceipt('session-1', batch({ removed: [RECEIPT] })) + noticeStructuredReviewReplyReceipt('session-1', batch({ items: [RECEIPT] })) + expect(settled).toHaveBeenCalledOnce() + + const line = vi.fn() + watchStructuredReviewReplySettled('session-1', line) + noticeStructuredReviewReplyReceipt('session-1', batch({ items: [RECEIPT] })) + expect(line).toHaveBeenCalledOnce() + }) + + it('dies when disposed, when its chat closes, or when the window passes', () => { + vi.useFakeTimers() + const disposed = vi.fn() + const closed = vi.fn() + const expired = vi.fn() + watchStructuredReviewReplySettled('session-1', disposed)() + watchStructuredReviewReplySettled('session-1', closed) + dropStructuredReviewReplyWatchers('session-1') + watchStructuredReviewReplySettled('session-1', expired, 1000) + vi.advanceTimersByTime(1001) + + noticeStructuredReviewReplyReceipt('session-1', batch({ removed: [RECEIPT] })) + + expect(disposed).not.toHaveBeenCalled() + expect(closed).not.toHaveBeenCalled() + expect(expired).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-review-reply-settled.ts b/src/renderer/src/lib/structured-agent-session-review-reply-settled.ts new file mode 100644 index 000000000000..79acaaa3d734 --- /dev/null +++ b/src/renderer/src/lib/structured-agent-session-review-reply-settled.ts @@ -0,0 +1,75 @@ +// Tells a source that handed a chat its review reply when that chat's host has written it: the +// receipt (a failure line or a tombstone) reaching this client in a live batch. The checks panel +// refetches the PR then, which matters where the chat's host is a paired server whose own +// mutation notice never reaches this client. + +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { + AGENT_SESSION_REVIEW_REPLY_WINDOW_MS, + isAgentSessionReviewReplyReceiptKey +} from '../../../shared/agent-session-review-reply' + +type Watcher = { run: () => void; dispose: () => void } + +const watchers = new Map>() + +function forget(sessionId: string, watcher: Watcher): void { + const set = watchers.get(sessionId) + set?.delete(watcher) + if (set?.size === 0) { + watchers.delete(sessionId) + } +} + +/** + * Runs `onSettled` once, the first time a receipt reaches this chat's live stream. The watch dies + * on its run, on the returned dispose, when the chat closes, or after the window in which the host + * still owes the reply. + */ +export function watchStructuredReviewReplySettled( + sessionId: string, + onSettled: () => void, + lifetimeMs: number = AGENT_SESSION_REVIEW_REPLY_WINDOW_MS +): () => void { + let timer: ReturnType | undefined + const watcher: Watcher = { + run: () => { + watcher.dispose() + onSettled() + }, + dispose: () => { + clearTimeout(timer) + forget(sessionId, watcher) + } + } + timer = setTimeout(watcher.dispose, lifetimeMs) + const set = watchers.get(sessionId) ?? new Set() + set.add(watcher) + watchers.set(sessionId, set) + return watcher.dispose +} + +/** The chat closed: nothing it would have said is coming. */ +export function dropStructuredReviewReplyWatchers(sessionId: string): void { + for (const watcher of Array.from(watchers.get(sessionId) ?? [])) { + watcher.dispose() + } +} + +/** Called with each live event of a chat's stream; a snapshot is history, not news. */ +export function noticeStructuredReviewReplyReceipt( + sessionId: string, + event: AgentSessionSubscribeEvent +): void { + if (!watchers.has(sessionId) || event.type !== 'batch') { + return + } + const touched = + event.batch.items.some((item) => isAgentSessionReviewReplyReceiptKey(item.itemId)) || + event.batch.removedItemIds.some(isAgentSessionReviewReplyReceiptKey) + if (touched) { + for (const watcher of Array.from(watchers.get(sessionId) ?? [])) { + watcher.run() + } + } +} diff --git a/src/renderer/src/runtime/structured-agent-session-tab-retirement.test.ts b/src/renderer/src/runtime/structured-agent-session-tab-retirement.test.ts index e0c9c562b780..423843933c04 100644 --- a/src/renderer/src/runtime/structured-agent-session-tab-retirement.test.ts +++ b/src/renderer/src/runtime/structured-agent-session-tab-retirement.test.ts @@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({ callRuntime: vi.fn<(target: RuntimeClientTarget, method: string, params?: unknown) => Promise>(), discardOutbox: vi.fn<(sessionId: string) => void>(), + dropReviewReplyWatchers: vi.fn<(sessionId: string) => void>(), hasTombstone: vi.fn<(worktreeId: string, sessionId: string) => boolean>(), markCancelled: vi.fn<(worktreeId: string, sessionId: string, executionHostId: string) => boolean>() @@ -20,6 +21,9 @@ vi.mock('@/lib/structured-agent-session-launch-registry', () => ({ vi.mock('@/components/native-chat/structured-agent-session-outbox-storage', () => ({ discardStructuredAgentSessionLaunchOutbox: mocks.discardOutbox })) +vi.mock('@/lib/structured-agent-session-review-reply-settled', () => ({ + dropStructuredReviewReplyWatchers: mocks.dropReviewReplyWatchers +})) vi.mock('./structured-agent-session-close', () => ({ closeStructuredAgentSession: mocks.closeSession })) @@ -90,6 +94,8 @@ describe('structured agent session tab retirement', () => { }) expect(mocks.markCancelled).toHaveBeenCalledWith('wt-1', 'session-1', 'local') expect(mocks.discardOutbox).toHaveBeenCalledWith('session-1') + // A source waiting on the chat's review-reply receipt hears nothing more from it. + expect(mocks.dropReviewReplyWatchers).toHaveBeenCalledWith('session-1') await vi.waitFor(() => expect(mocks.callRuntime).toHaveBeenCalled()) expect(mocks.closeSession).toHaveBeenCalledWith(target, 'session-1') }) diff --git a/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts b/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts index 86e475684802..f085dc07a4d4 100644 --- a/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts +++ b/src/renderer/src/runtime/structured-agent-session-tab-retirement.ts @@ -1,3 +1,4 @@ +import { dropStructuredReviewReplyWatchers } from '@/lib/structured-agent-session-review-reply-settled' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' import { markStructuredAgentSessionLaunchesPublished, @@ -77,6 +78,7 @@ export function beginStructuredAgentSessionTabClose(args: { ) } discardStructuredAgentSessionLaunchOutbox(args.sessionId) + dropStructuredReviewReplyWatchers(args.sessionId) retireStructuredAgentSessionTab(args) } diff --git a/src/shared/agent-session-review-reply.ts b/src/shared/agent-session-review-reply.ts index 059818cd7714..46415fcc42e5 100644 --- a/src/shared/agent-session-review-reply.ts +++ b/src/shared/agent-session-review-reply.ts @@ -3,6 +3,26 @@ // cannot (GitHub only). It rides the message, so whichever host records the message runs it. import { z } from 'zod' +import { parseAgentJournalItemKey } from './agent-session-journal-item-key' + +/** A message accepted longer ago than this owes no review reply: a crash's leftover must not post + * days later on some reopen. Measured from acceptance, so a late Retry still posts. */ +export const AGENT_SESSION_REVIEW_REPLY_WINDOW_MS = 24 * 60 * 60 * 1000 + +/** The receipt's item rides the message's own id with this suffix: a line, or a tombstone. */ +const REVIEW_REPLY_RECEIPT_SUFFIX = '#review-reply' + +export function agentSessionReviewReplyReceiptMessageId(clientMessageId: string): string { + return `${clientMessageId}${REVIEW_REPLY_RECEIPT_SUFFIX}` +} + +/** Whether a journal item id is a review reply's receipt, written or tombstoned. */ +export function isAgentSessionReviewReplyReceiptKey(itemId: string): boolean { + const identity = parseAgentJournalItemKey(itemId) + return ( + identity?.provider === 'orca' && identity.clientMessageId.endsWith(REVIEW_REPLY_RECEIPT_SUFFIX) + ) +} /** Bounds a spec so a message cannot carry an unbounded side effect. */ export const MAX_REVIEW_REPLY_TARGETS = 500 From a43c439ca6e7a7d7c20d1cf11363d31741f53693 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:20:39 -0700 Subject: [PATCH 78/84] fix(checks-panel): an armed review-reply watch keeps its chat's read open, and refreshes only the review still shown Three round-7 fixes to the refetch: - The receipt notice moves from the read transport to the chat's read owner, its layer; the owner's older-page reader moves to its own module to make room. - A chat's read runs only while its pane shows or its outbox owes a delivery, and on a paired host the entry retires before the host writes, so a hidden chat heard nothing. An armed watch now holds the owner's read open, as owed work does, and lets go when it fires, with the panel, when the chat closes, or after the window. - The deferred refetch used the closure of the launch's render, possibly hours later, with no check that the panel still shows that review; a stale fetch could leave "Comments are still loading." stuck. It now calls the panel's latest refresh, and only while the panel shows the launch's review (head changes allowed). The watch lives in its own hook. --- ...ructured-agent-session-read-older-pages.ts | 105 ++++++++++++++++++ ...nt-session-read-owner-review-reply.test.ts | 76 +++++++++++++ .../structured-agent-session-read-owner.ts | 91 ++++----------- ...ession-read-transport-review-reply.test.ts | 61 ---------- .../comment-resolution-review-reply.test.tsx | 54 ++++++++- .../use-checks-panel-ai-acknowledgement.tsx | 28 ++--- .../use-checks-panel-review-reply-refetch.tsx | 60 ++++++++++ .../runSourceControlAgentActionStart.ts | 32 ++++-- ...agent-session-review-reply-settled.test.ts | 25 ++++- ...ured-agent-session-review-reply-settled.ts | 17 ++- ...ured-agent-session-review-reply-support.ts | 20 +++- 11 files changed, 392 insertions(+), 177 deletions(-) create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-read-older-pages.ts create mode 100644 src/renderer/src/components/native-chat/structured-agent-session-read-owner-review-reply.test.ts delete mode 100644 src/renderer/src/components/native-chat/structured-agent-session-read-transport-review-reply.test.ts create mode 100644 src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-reply-refetch.tsx diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-older-pages.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-older-pages.ts new file mode 100644 index 000000000000..3ecfb25f0681 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-older-pages.ts @@ -0,0 +1,105 @@ +// A chat's older history, one page at a time behind the live window: concurrent asks share one +// page, and a page whose anchor a live batch slid past is read again rather than leave a hole. + +import { + AGENT_SESSION_HISTORY_MAX_LIMIT, + type AgentSessionHistoryResult +} from '../../../../shared/agent-session-wire' +import { + oldestStructuredAgentSessionCursor, + type StructuredAgentSessionAction, + type StructuredAgentSessionState +} from '../../../../shared/structured-agent-session-reducer' +import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import type { NativeChatOlderPageResult } from './native-chat-pagination' + +/** Bounded so a busy stream cannot turn one scroll-to-top into an endless read chain. */ +export const OLDER_PAGE_ANCHOR_ATTEMPTS = 3 + +export function createStructuredAgentSessionOlderPageReader(args: { + sessionId: string + target: RuntimeClientTarget + getState: () => StructuredAgentSessionState + apply: (action: StructuredAgentSessionAction) => void + /** The live read's guard: a page read under one that has since stopped is superseded. */ + captureHistoryReadGuard: () => () => boolean + markLoadingOlder: () => void + clearLoadingOlder: () => void +}): { loadOlder: () => Promise } { + let olderPage: { shouldStop: () => boolean; promise: Promise } | null = + null + const readOlderPage = async (shouldStop: () => boolean): Promise => { + try { + // A live batch can head-trim past the anchor mid-read, and the reducer drops + // that page rather than leave a hole in the transcript. Re-anchor and retry. + for (let attempt = 0; attempt < OLDER_PAGE_ANCHOR_ATTEMPTS; attempt += 1) { + const cursor = oldestStructuredAgentSessionCursor(args.getState()) + if (shouldStop()) { + return 'superseded' + } + if (!cursor) { + return 'exhausted' + } + const result = await callStructuredAgentSession( + args.target, + 'agentSession.history', + { + sessionId: args.sessionId, + direction: 'before', + cursor, + limit: AGENT_SESSION_HISTORY_MAX_LIMIT + } + ) + if (shouldStop()) { + return 'superseded' + } + if (!result.ok) { + return 'failed' + } + // The reducer drops a page whose anchor slid, so only an intact anchor lands. + if (oldestStructuredAgentSessionCursor(args.getState())?.sequence === cursor.sequence) { + args.apply({ type: 'older-page', requestedCursor: cursor, page: result.page }) + if (oldestStructuredAgentSessionCursor(args.getState())?.sequence !== cursor.sequence) { + return 'applied' + } + return args.getState().hasOlder ? 'unchanged' : 'exhausted' + } + } + return 'unchanged' + } catch { + // A failed page leaves the loaded conversation intact; the list offers a retry, and + // the next invalidation (re-attach, snapshot, reset) re-enables paging. + return shouldStop() ? 'superseded' : 'failed' + } + } + + const loadOlder = (): Promise => { + // Concurrent callers (scroll-to-top, the button, a rail jump) share one page + // and its result rather than reading a refusal as "no progress". + if (olderPage && !olderPage.shouldStop()) { + return olderPage.promise + } + const shouldStop = args.captureHistoryReadGuard() + if (shouldStop()) { + return Promise.resolve('superseded') + } + if (!oldestStructuredAgentSessionCursor(args.getState()) || !args.getState().hasOlder) { + return Promise.resolve('exhausted') + } + args.markLoadingOlder() + const page = { shouldStop, promise: readOlderPage(shouldStop) } + olderPage = page + void page.promise.finally(() => { + if (olderPage !== page) { + return + } + olderPage = null + if (!shouldStop()) { + args.clearLoadingOlder() + } + }) + return page.promise + } + return { loadOlder } +} diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-owner-review-reply.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-owner-review-reply.test.ts new file mode 100644 index 000000000000..0c20167809c1 --- /dev/null +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-owner-review-reply.test.ts @@ -0,0 +1,76 @@ +// @vitest-environment happy-dom + +import { afterEach, expect, it, vi } from 'vitest' +import type { AgentSessionSubscribeEvent } from '../../../../shared/agent-session-wire' + +const mocks = vi.hoisted( + (): { + applyEvent?: (event: AgentSessionSubscribeEvent) => void + started: number + disposed: number + } => ({ started: 0, disposed: 0 }) +) + +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: vi.fn(), + subscribeStructuredAgentSession: vi.fn() +})) +vi.mock('./structured-agent-session-read-transport', () => ({ + startStructuredAgentSessionReadTransport: (args: { + applyEvent: (event: AgentSessionSubscribeEvent) => void + }) => { + mocks.started += 1 + mocks.applyEvent = args.applyEvent + return { + captureHistoryReadGuard: () => () => false, + dispose: () => { + mocks.disposed += 1 + } + } + } +})) + +import { agentJournalItemKey } from '../../../../shared/agent-session-journal-item-key' +import { agentSessionReviewReplyReceiptMessageId } from '../../../../shared/agent-session-review-reply' +import { watchStructuredReviewReplySettled } from '@/lib/structured-agent-session-review-reply-settled' +import { + getStructuredAgentSessionReadOwner, + resetStructuredAgentSessionReadOwnersForTests +} from './structured-agent-session-read-owner' + +afterEach(() => { + resetStructuredAgentSessionReadOwnersForTests() +}) + +function receipt(): AgentSessionSubscribeEvent { + return { + type: 'batch', + sessionId: 'session-1', + batch: { + cursor: { epoch: 'e', sequence: 3 }, + items: [], + removedItemIds: [ + agentJournalItemKey({ + provider: 'orca', + clientMessageId: agentSessionReviewReplyReceiptMessageId('message-1') + }) + ], + submissions: [] + } + } +} + +it("hears a hidden chat's review-reply receipt: the armed watch keeps the chat's read open", () => { + const settled = vi.fn() + // No pane shows the chat: only the watch activates its read. + watchStructuredReviewReplySettled('session-1', settled, { + holdRead: () => getStructuredAgentSessionReadOwner('session-1', { kind: 'local' }).activate() + }) + expect(mocks.started).toBe(1) + + mocks.applyEvent?.(receipt()) + + expect(settled).toHaveBeenCalledOnce() + // Fired, so the read it held is let go. + expect(mocks.disposed).toBe(1) +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts index e3f87a8e95e6..a6d3b6a66a5b 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts @@ -16,6 +16,11 @@ import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { NATIVE_CHAT_INITIAL_LIMIT, type NativeChatOlderPageResult } from './native-chat-pagination' import { startStructuredAgentSessionReadTransport } from './structured-agent-session-read-transport' +import { noticeStructuredReviewReplyReceipt } from '@/lib/structured-agent-session-review-reply-settled' +import { + createStructuredAgentSessionOlderPageReader, + OLDER_PAGE_ANCHOR_ATTEMPTS +} from './structured-agent-session-read-older-pages' export type StructuredAgentSessionReadSnapshot = { state: StructuredAgentSessionState @@ -35,9 +40,6 @@ export type StructuredAgentSessionReadOwner = { const owners = new Map() -/** Bounded so a busy stream cannot turn one scroll-to-top into an endless read chain. */ -const OLDER_PAGE_ANCHOR_ATTEMPTS = 3 - function countsTowardInitialHistory(item: AgentJournalRenderItem): boolean { return item.body.kind !== 'status' || !item.body.providerFrame } @@ -175,54 +177,25 @@ function createReadOwner( } } - let olderPage: { shouldStop: () => boolean; promise: Promise } | null = - null - const readOlderPage = async (shouldStop: () => boolean): Promise => { - try { - // A live batch can head-trim past the anchor mid-read, and the reducer drops - // that page rather than leave a hole in the transcript. Re-anchor and retry. - for (let attempt = 0; attempt < OLDER_PAGE_ANCHOR_ATTEMPTS; attempt += 1) { - const cursor = oldestStructuredAgentSessionCursor(snapshot.state) - if (shouldStop()) { - return 'superseded' - } - if (!cursor) { - return 'exhausted' - } - const result = await callStructuredAgentSession( - target, - 'agentSession.history', - { sessionId, direction: 'before', cursor, limit: AGENT_SESSION_HISTORY_MAX_LIMIT } - ) - if (shouldStop()) { - return 'superseded' - } - if (!result.ok) { - return 'failed' - } - // The reducer drops a page whose anchor slid, so only an intact anchor lands. - if (oldestStructuredAgentSessionCursor(snapshot.state)?.sequence === cursor.sequence) { - apply({ type: 'older-page', requestedCursor: cursor, page: result.page }) - if (oldestStructuredAgentSessionCursor(snapshot.state)?.sequence !== cursor.sequence) { - return 'applied' - } - return snapshot.state.hasOlder ? 'unchanged' : 'exhausted' - } - } - return 'unchanged' - } catch { - // A failed page leaves the loaded conversation intact; the list offers a retry, and - // the next invalidation (re-attach, snapshot, reset) re-enables paging. - return shouldStop() ? 'superseded' : 'failed' - } - } + const olderPages = createStructuredAgentSessionOlderPageReader({ + sessionId, + target, + getState: () => snapshot.state, + apply, + captureHistoryReadGuard: () => captureActiveHistoryReadGuard(), + markLoadingOlder: () => setSnapshot({ ...snapshot, loadingOlder: true }), + clearLoadingOlder + }) const start = (): void => { if (snapshot.state.epoch === null) { apply({ type: 'loading' }) } const transport = startStructuredAgentSessionReadTransport({ - applyEvent: (event) => apply({ type: 'event', event }), + applyEvent: (event) => { + apply({ type: 'event', event }) + noticeStructuredReviewReplyReceipt(sessionId, event) + }, applyError: (message, refusal) => apply({ type: 'error', message, refusal }), getCursor: () => snapshot.state.cursor, onHistoryReadInvalidated: invalidateOlderPages, @@ -265,33 +238,7 @@ function createReadOwner( stopActiveRun?.() }, getSnapshot: () => snapshot, - loadOlder: () => { - // Concurrent callers (scroll-to-top, the button, a rail jump) share one page - // and its result rather than reading a refusal as "no progress". - if (olderPage && !olderPage.shouldStop()) { - return olderPage.promise - } - const shouldStop = captureActiveHistoryReadGuard() - if (shouldStop()) { - return Promise.resolve('superseded') - } - if (!oldestStructuredAgentSessionCursor(snapshot.state) || !snapshot.state.hasOlder) { - return Promise.resolve('exhausted') - } - setSnapshot({ ...snapshot, loadingOlder: true }) - const page = { shouldStop, promise: readOlderPage(shouldStop) } - olderPage = page - void page.promise.finally(() => { - if (olderPage !== page) { - return - } - olderPage = null - if (!shouldStop()) { - clearLoadingOlder() - } - }) - return page.promise - }, + loadOlder: olderPages.loadOlder, subscribe: (listener) => { listeners.add(listener) return () => { diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport-review-reply.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport-review-reply.test.ts deleted file mode 100644 index 2450a7e49990..000000000000 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-transport-review-reply.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { afterEach, expect, it, vi } from 'vitest' -import type { AgentSessionSubscribeEvent } from '../../../../shared/agent-session-wire' - -const mocks = vi.hoisted(() => ({ subscribe: vi.fn(), watchHostContact: vi.fn() })) - -vi.mock('@/runtime/structured-agent-session-client', () => ({ - subscribeStructuredAgentSession: mocks.subscribe -})) -vi.mock('@/runtime/runtime-host-contact-regained', () => ({ - subscribeRuntimeHostContactRegained: mocks.watchHostContact -})) - -import { agentJournalItemKey } from '../../../../shared/agent-session-journal-item-key' -import { agentSessionReviewReplyReceiptMessageId } from '../../../../shared/agent-session-review-reply' -import { watchStructuredReviewReplySettled } from '@/lib/structured-agent-session-review-reply-settled' -import { startStructuredAgentSessionReadTransport } from './structured-agent-session-read-transport' - -afterEach(() => { - vi.useRealTimers() -}) - -it("tells a review reply's watcher when the chat's live stream brings its receipt", async () => { - vi.useFakeTimers() - let onEvent: (event: AgentSessionSubscribeEvent) => void = () => {} - mocks.subscribe.mockImplementation((_target, _params, listener) => { - onEvent = listener - return new Promise(() => {}) - }) - const settled = vi.fn() - const dispose = watchStructuredReviewReplySettled('session-a', settled) - const transport = startStructuredAgentSessionReadTransport({ - applyEvent: () => {}, - applyError: vi.fn(), - getCursor: () => null, - onHistoryReadInvalidated: () => undefined, - sessionId: 'session-a', - target: { kind: 'local' } - }) - await vi.advanceTimersByTimeAsync(0) - - onEvent({ - type: 'batch', - sessionId: 'session-a', - batch: { - cursor: { epoch: 'epoch-a', sequence: 3 }, - items: [], - removedItemIds: [ - agentJournalItemKey({ - provider: 'orca', - clientMessageId: agentSessionReviewReplyReceiptMessageId('message-1') - }) - ], - submissions: [] - } - }) - await vi.advanceTimersByTimeAsync(100) - - expect(settled).toHaveBeenCalledOnce() - transport.dispose() - dispose() -}) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx index 33ee43072f70..ea07478d18bd 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx @@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ hostRunsReviewReplies: vi.fn(async () => true), + readHolds: [] as { sessionId: string; target: unknown; released: boolean }[], launchAgentInNewTab: vi.fn(), toastError: vi.fn(), toastSuccess: vi.fn() @@ -17,7 +18,19 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab: mocks.launchAgentInNewTab })) vi.mock('@/lib/focus-terminal-tab-surface', () => ({ focusTerminalTabSurface: vi.fn() })) vi.mock('@/lib/structured-agent-session-review-reply-support', () => ({ - structuredChatHostRunsReviewReplies: mocks.hostRunsReviewReplies + structuredChatHostRunsReviewReplies: mocks.hostRunsReviewReplies, + structuredChatTargetForWorktree: () => ({ kind: 'environment', environmentId: 'paired-1' }) +})) +vi.mock('@/components/native-chat/structured-agent-session-read-owner', () => ({ + getStructuredAgentSessionReadOwner: (sessionId: string, target: unknown) => ({ + activate: () => { + const hold = { sessionId, target, released: false } + mocks.readHolds.push(hold) + return () => { + hold.released = true + } + } + }) })) vi.mock('sonner', () => ({ toast: { error: mocks.toastError, success: mocks.toastSuccess } })) @@ -196,6 +209,7 @@ describe('Resolve comments with AI', () => { } it('refetches the PR once the chat says its host wrote, on this host or a paired one', async () => { + mocks.readHolds.length = 0 const { model, hook } = acknowledgement() mocks.launchAgentInNewTab.mockReturnValue({ surface: { kind: 'host-published' }, @@ -205,10 +219,48 @@ describe('Resolve comments with AI', () => { await expect(resolveCommentsWithAi(hook)).resolves.toBe(true) expect(model.fetchComments).not.toHaveBeenCalled() + // While it waits, the chat's read stays open on its host, shown or not. + expect(mocks.readHolds).toEqual([ + { + sessionId: 'paired-session', + target: { kind: 'environment', environmentId: 'paired-1' }, + released: false + } + ]) + receiptArrives('paired-session') receiptArrives('paired-session') expect(model.fetchComments).toHaveBeenCalledExactlyOnceWith({ force: true }) + expect(mocks.readHolds[0]?.released).toBe(true) + }) + + it("refetches with the panel's latest fetch after the agent pushed, and not at all on another PR", async () => { + const { model, hook } = acknowledgement() + mocks.launchAgentInNewTab.mockReturnValue({ + surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'session-1' }, + promptDeliveryResult: CARRIED, + structuredSettlement: Promise.resolve({ kind: 'structured', sessionId: 'session-1' }) + }) + await expect(resolveCommentsWithAi(hook)).resolves.toBe(true) + // The agent pushed: same PR, new head, and the panel's fetch was rebuilt for it. + const fetchAtNewHead = vi.fn(async () => {}) + model.asyncResultKeyRef.current = 'repo-1::42::sha-2' + model.fetchComments = fetchAtNewHead + hook.rerender() + + receiptArrives('session-1') + + expect(fetchAtNewHead).toHaveBeenCalledExactlyOnceWith({ force: true }) + + const again = acknowledgement() + await expect(resolveCommentsWithAi(again.hook)).resolves.toBe(true) + again.model.asyncResultKeyRef.current = 'repo-1::77::sha-9' + again.hook.rerender() + + receiptArrives('session-1') + + expect(again.model.fetchComments).not.toHaveBeenCalled() }) it('stops waiting for the receipt when the panel goes away', async () => { diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx index 7825388682d9..73bf7852fe39 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx @@ -14,7 +14,7 @@ import { } from '../pr-comments-ai-launch-ack' import { buildPRCommentReviewReply } from '../pr-comment-review-reply-spec' import type { SourceControlAgentLaunched } from '../runSourceControlAgentActionStart' -import { watchStructuredReviewReplySettled } from '@/lib/structured-agent-session-review-reply-settled' +import { useChecksPanelReviewReplyRefetch } from './use-checks-panel-review-reply-refetch' import type { AgentSessionReviewReply } from '../../../../../shared/agent-session-review-reply' import type { ChecksPanelControllerState } from './use-checks-panel-controller-state' import type { ChecksPanelReviewDataState } from './use-checks-panel-review-data' @@ -309,17 +309,10 @@ export function useChecksPanelAiAcknowledgement(model: ChecksPanelAiAcknowledgem commentResolutionLaunchAcceptedRef ]) - // Each hand-off's refetch watch; they die with the panel. - const settledWatches = useRef(new Set<() => void>()) - useEffect(() => { - const watches = settledWatches.current - return () => { - for (const dispose of watches) { - dispose() - } - watches.clear() - } - }, []) + const refetchOnceChatWrote = useChecksPanelReviewReplyRefetch({ + asyncResultKeyRef, + refreshCommentsAfterBulkResolve + }) /** What a structured chat's launch prompt carries for its host to write once the agent takes it. */ const buildLaunchReviewReply = useCallback((): AgentSessionReviewReply | undefined => { @@ -347,13 +340,8 @@ export function useChecksPanelAiAcknowledgement(model: ChecksPanelAiAcknowledgem setCommentResolutionAckBusyNow(false) // The chat's host may be a paired server, whose own mutation notice never reaches this // client: refetch once the chat says its host wrote. - if (launch.sessionId) { - const provider = resolution.provider - const dispose = watchStructuredReviewReplySettled(launch.sessionId, () => { - settledWatches.current.delete(dispose) - void refreshCommentsAfterBulkResolve(provider) - }) - settledWatches.current.add(dispose) + if (launch.chat) { + refetchOnceChatWrote(launch.chat, resolution) } // The one write the chat's host can't make: without the PR's repository it can't reply. if ( @@ -380,7 +368,7 @@ export function useChecksPanelAiAcknowledgement(model: ChecksPanelAiAcknowledgem }, [ clearSentCommentSelection, - refreshCommentsAfterBulkResolve, + refetchOnceChatWrote, resolveSelectedThreadsAfterLaunch, setCommentResolutionAckBusyNow, claimedCommentResolutionRef, diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-reply-refetch.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-reply-refetch.tsx new file mode 100644 index 000000000000..4866d838d17f --- /dev/null +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-review-reply-refetch.tsx @@ -0,0 +1,60 @@ +import { useCallback, useEffect, useRef, type RefObject } from 'react' +import { getStructuredAgentSessionReadOwner } from '@/components/native-chat/structured-agent-session-read-owner' +import { watchStructuredReviewReplySettled } from '@/lib/structured-agent-session-review-reply-settled' +import { + checksPanelReviewStableKey, + type PendingPRCommentAiAck +} from '../pr-comments-ai-launch-ack' +import type { SourceControlAgentLaunched } from '../runSourceControlAgentActionStart' +import type { ChecksPanelReview } from '../checks-panel-review' + +/** + * Refetches the PR once a structured chat says its host wrote the review reply it was handed: the + * host may be a paired server, whose own mutation notice never reaches this client. Each watch + * keeps the chat's read open while armed (owed work does, so a hidden chat still hears it) and + * dies once run, with the panel, when the chat closes, or after the reply's window. + */ +export function useChecksPanelReviewReplyRefetch(args: { + asyncResultKeyRef: RefObject + refreshCommentsAfterBulkResolve: (provider: ChecksPanelReview['provider']) => Promise +}): ( + chat: NonNullable, + resolution: PendingPRCommentAiAck +) => void { + const { asyncResultKeyRef, refreshCommentsAfterBulkResolve } = args + const watches = useRef(new Set<() => void>()) + const refreshLatest = useRef(refreshCommentsAfterBulkResolve) + useEffect(() => { + refreshLatest.current = refreshCommentsAfterBulkResolve + }, [refreshCommentsAfterBulkResolve]) + useEffect(() => { + const armed = watches.current + return () => { + for (const dispose of armed) { + dispose() + } + armed.clear() + } + }, []) + return useCallback( + (chat, resolution) => { + const provider = resolution.provider + const launchKey = checksPanelReviewStableKey(resolution.reviewContextKey) + const dispose = watchStructuredReviewReplySettled( + chat.sessionId, + () => { + watches.current.delete(dispose) + // Possibly hours later: only the review the panel shows now, with its fetch of now. + if (checksPanelReviewStableKey(asyncResultKeyRef.current) === launchKey) { + void refreshLatest.current(provider) + } + }, + { + holdRead: () => getStructuredAgentSessionReadOwner(chat.sessionId, chat.target).activate() + } + ) + watches.current.add(dispose) + }, + [asyncResultKeyRef] + ) +} diff --git a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts index f8c15cec0ceb..875be2ee736a 100644 --- a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts @@ -1,7 +1,11 @@ import type { AgentSessionReviewReply } from '../../../../shared/agent-session-review-reply' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' -import { structuredChatHostRunsReviewReplies } from '@/lib/structured-agent-session-review-reply-support' +import { + structuredChatHostRunsReviewReplies, + structuredChatTargetForWorktree +} from '@/lib/structured-agent-session-review-reply-support' +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { Repo } from '../../../../shared/repo-types' import type { TuiAgent } from '../../../../shared/tui-agent' @@ -56,7 +60,7 @@ type RunSourceControlAgentActionStartArgs = { onLaunchAborted?: () => void /** `reviewReplyCarried`: a structured chat's message carries the review reply, which its host * writes once the agent takes it, so the caller writes nothing to the review itself. - * `sessionId` names that chat, when known, so the caller can refresh once its host wrote. */ + * `chat` names that chat and its host, when known, so the caller can refresh once it wrote. */ onLaunched?: (launch: SourceControlAgentLaunched) => void /** Planned at start: what a structured chat's host writes on the review once its agent takes * the prompt. A terminal agent has no message to carry it. */ @@ -64,18 +68,24 @@ type RunSourceControlAgentActionStartArgs = { onClose: () => void } -export type SourceControlAgentLaunched = { reviewReplyCarried: boolean; sessionId?: string } +export type SourceControlAgentLaunched = { + reviewReplyCarried: boolean + chat?: { sessionId: string; target: RuntimeClientTarget } +} /** The chat a structured launch opened: this client's own from its surface, a paired host's from * the settlement that learned it. */ -async function structuredChatSessionId( - result: NonNullable> -): Promise { +async function structuredChat( + result: NonNullable>, + worktreeId: string +): Promise { if (result.surface.kind === 'local-agent-session') { - return result.surface.sessionId + return { sessionId: result.surface.sessionId, target: { kind: 'local' } } } const settled = await result.structuredSettlement?.catch(() => undefined) - return settled?.kind === 'structured' ? settled.sessionId : undefined + return settled?.kind === 'structured' + ? { sessionId: settled.sessionId, target: structuredChatTargetForWorktree(worktreeId) } + : undefined } export async function runSourceControlAgentActionStart({ @@ -104,7 +114,7 @@ export async function runSourceControlAgentActionStart({ }: RunSourceControlAgentActionStartArgs): Promise { let launched = false let reviewReplyCarried = false - let reviewReplySessionId: string | undefined + let reviewReplyChat: SourceControlAgentLaunched['chat'] let launchFailureNotified = false let launchAcceptedNotified = false // Why: `undefined` is what makes the launch fall back to the global Agents arguments; @@ -159,7 +169,7 @@ export async function runSourceControlAgentActionStart({ // which carries no review reply, so the panel writes after its paste. reviewReplyCarried = deliveryResult.reviewReplyCarried === true if (reviewReplyCarried) { - reviewReplySessionId = await structuredChatSessionId(result) + reviewReplyChat = await structuredChat(result, worktreeId) } // A chat that holds its review reply sends it again itself; offering Start again here // would launch a second chat, and post the replies twice. @@ -214,7 +224,7 @@ export async function runSourceControlAgentActionStart({ } onLaunched?.({ reviewReplyCarried, - ...(reviewReplySessionId ? { sessionId: reviewReplySessionId } : {}) + ...(reviewReplyChat ? { chat: reviewReplyChat } : {}) }) onClose() return true diff --git a/src/renderer/src/lib/structured-agent-session-review-reply-settled.test.ts b/src/renderer/src/lib/structured-agent-session-review-reply-settled.test.ts index 931b52af5b3c..a57df0a8ae09 100644 --- a/src/renderer/src/lib/structured-agent-session-review-reply-settled.test.ts +++ b/src/renderer/src/lib/structured-agent-session-review-reply-settled.test.ts @@ -41,6 +41,29 @@ afterEach(() => { }) describe("a source's watch for its chat's review-reply receipt", () => { + it("holds the chat's read open while armed, and lets go however it ends", () => { + vi.useFakeTimers() + const holds = [vi.fn(), vi.fn(), vi.fn(), vi.fn()] + const hold = (index: number) => () => holds[index]! + watchStructuredReviewReplySettled('session-1', vi.fn(), { holdRead: hold(0) }) + const dispose = watchStructuredReviewReplySettled('session-2', vi.fn(), { holdRead: hold(1) }) + watchStructuredReviewReplySettled('session-3', vi.fn(), { holdRead: hold(2) }) + watchStructuredReviewReplySettled('session-4', vi.fn(), { + holdRead: hold(3), + lifetimeMs: 1000 + }) + expect(holds.some((release) => release.mock.calls.length > 0)).toBe(false) + + noticeStructuredReviewReplyReceipt('session-1', batch({ removed: [RECEIPT] })) + dispose() + dropStructuredReviewReplyWatchers('session-3') + vi.advanceTimersByTime(1001) + + for (const release of holds) { + expect(release).toHaveBeenCalledOnce() + } + }) + it('runs once, on a written line or a tombstone in a live batch, and only for that chat', () => { const settled = vi.fn() watchStructuredReviewReplySettled('session-1', settled) @@ -67,7 +90,7 @@ describe("a source's watch for its chat's review-reply receipt", () => { watchStructuredReviewReplySettled('session-1', disposed)() watchStructuredReviewReplySettled('session-1', closed) dropStructuredReviewReplyWatchers('session-1') - watchStructuredReviewReplySettled('session-1', expired, 1000) + watchStructuredReviewReplySettled('session-1', expired, { lifetimeMs: 1000 }) vi.advanceTimersByTime(1001) noticeStructuredReviewReplyReceipt('session-1', batch({ removed: [RECEIPT] })) diff --git a/src/renderer/src/lib/structured-agent-session-review-reply-settled.ts b/src/renderer/src/lib/structured-agent-session-review-reply-settled.ts index 79acaaa3d734..14ff40e2641b 100644 --- a/src/renderer/src/lib/structured-agent-session-review-reply-settled.ts +++ b/src/renderer/src/lib/structured-agent-session-review-reply-settled.ts @@ -1,5 +1,5 @@ // Tells a source that handed a chat its review reply when that chat's host has written it: the -// receipt (a failure line or a tombstone) reaching this client in a live batch. The checks panel +// receipt (a failure line or a tombstone) reaching this client in the chat's live read. The checks panel // refetches the PR then, which matters where the chat's host is a paired server whose own // mutation notice never reaches this client. @@ -22,16 +22,18 @@ function forget(sessionId: string, watcher: Watcher): void { } /** - * Runs `onSettled` once, the first time a receipt reaches this chat's live stream. The watch dies - * on its run, on the returned dispose, when the chat closes, or after the window in which the host - * still owes the reply. + * Runs `onSettled` once, the first time a receipt reaches this chat's live stream. `holdRead` keeps + * that stream open while the watch is armed, as owed work does, so a hidden chat still hears it. + * The watch, and its hold, die on its run, on the returned dispose, when the chat closes, or after + * the window in which the host still owes the reply. */ export function watchStructuredReviewReplySettled( sessionId: string, onSettled: () => void, - lifetimeMs: number = AGENT_SESSION_REVIEW_REPLY_WINDOW_MS + options: { holdRead?: () => () => void; lifetimeMs?: number } = {} ): () => void { let timer: ReturnType | undefined + let releaseRead: (() => void) | undefined const watcher: Watcher = { run: () => { watcher.dispose() @@ -39,13 +41,16 @@ export function watchStructuredReviewReplySettled( }, dispose: () => { clearTimeout(timer) + releaseRead?.() + releaseRead = undefined forget(sessionId, watcher) } } - timer = setTimeout(watcher.dispose, lifetimeMs) + timer = setTimeout(watcher.dispose, options.lifetimeMs ?? AGENT_SESSION_REVIEW_REPLY_WINDOW_MS) const set = watchers.get(sessionId) ?? new Set() set.add(watcher) watchers.set(sessionId, set) + releaseRead = options.holdRead?.() return watcher.dispose } diff --git a/src/renderer/src/lib/structured-agent-session-review-reply-support.ts b/src/renderer/src/lib/structured-agent-session-review-reply-support.ts index 3a290f1ea674..8040476779cc 100644 --- a/src/renderer/src/lib/structured-agent-session-review-reply-support.ts +++ b/src/renderer/src/lib/structured-agent-session-review-reply-support.ts @@ -7,14 +7,24 @@ import { resolveStructuredAgentSessionOwner, structuredAgentSessionTargetForHost } from '@/runtime/structured-agent-session-owner' -import { runtimeEnvironmentSupportsCapability } from '@/runtime/runtime-rpc-client' +import { + runtimeEnvironmentSupportsCapability, + type RuntimeClientTarget +} from '@/runtime/runtime-rpc-client' + +/** The host a structured chat in this workspace runs on; this client's own when none is named. */ +export function structuredChatTargetForWorktree(worktreeId: string): RuntimeClientTarget { + return ( + structuredAgentSessionTargetForHost( + resolveStructuredAgentSessionOwner(useAppStore.getState(), worktreeId) + ) ?? { kind: 'local' } + ) +} /** This client's own host always can; a paired one only once it says so. */ export async function structuredChatHostRunsReviewReplies(worktreeId: string): Promise { - const target = structuredAgentSessionTargetForHost( - resolveStructuredAgentSessionOwner(useAppStore.getState(), worktreeId) - ) - if (target?.kind !== 'environment') { + const target = structuredChatTargetForWorktree(worktreeId) + if (target.kind !== 'environment') { return true } return runtimeEnvironmentSupportsCapability( From 40d2e8c968c80f47d085335a6d29645f50c5346f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:24:00 -0700 Subject: [PATCH 79/84] test(checks-panel): the read-hold fixture types its list without an assertion --- .../checks-panel/comment-resolution-review-reply.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx index ea07478d18bd..d87e7d0d41c1 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx @@ -7,9 +7,11 @@ import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +type ReadHold = { sessionId: string; target: unknown; released: boolean } + const mocks = vi.hoisted(() => ({ hostRunsReviewReplies: vi.fn(async () => true), - readHolds: [] as { sessionId: string; target: unknown; released: boolean }[], + readHolds: new Array(), launchAgentInNewTab: vi.fn(), toastError: vi.fn(), toastSuccess: vi.fn() From 599fd1cf5bcd96fe62ac6f76cdce8cf76debaf50 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:35:02 -0700 Subject: [PATCH 80/84] fix(checks-panel): a review-reply watch reads the chat on the host the launch used, and lets go whenever the chat goes Round-8 fixes: - The read transport still noticed receipts after the move to the read owner, so each batch was checked twice and the wire module imported the panel's bus. The transport no longer does. - The hold's host was worked out again after delivery from what the UI showed then, which could name this machine for a paired chat and retry a refused read for 24 h. The launch now hands out the host it used (this client's own, or the paired server it asked), and the hold reads there. - The hold was let go on a user's tab close only. It now also goes on a close with no host to name, on a worktree purge, and when the host refuses the read because it holds no such chat. --- ...nt-session-read-owner-review-reply.test.ts | 21 ++++++++++ .../structured-agent-session-read-owner.ts | 13 +++++- ...tured-agent-session-read-transport.test.ts | 42 +++++++++++++++++++ ...structured-agent-session-read-transport.ts | 2 - .../comment-resolution-review-reply.test.tsx | 12 +++--- .../runSourceControlAgentActionStart.ts | 21 +++++----- ...aunch-agent-in-new-tab-structured-route.ts | 5 +++ .../src/lib/launch-agent-in-new-tab.ts | 3 ++ ...red-agent-session-paired-admission.test.ts | 16 +++++++ ...ured-agent-session-review-reply-support.ts | 2 +- .../tabs/structured-chat-close-owner.test.ts | 8 +++- .../store/slices/tabs/tabs-close-actions.ts | 3 ++ .../worktree-purge-review-reply.test.ts | 41 ++++++++++++++++++ .../teardown/worktree-purge-state.ts | 4 ++ 14 files changed, 171 insertions(+), 22 deletions(-) create mode 100644 src/renderer/src/store/slices/worktrees/teardown/worktree-purge-review-reply.test.ts diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-owner-review-reply.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-owner-review-reply.test.ts index 0c20167809c1..8151f9f5cdec 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-owner-review-reply.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-owner-review-reply.test.ts @@ -2,10 +2,12 @@ import { afterEach, expect, it, vi } from 'vitest' import type { AgentSessionSubscribeEvent } from '../../../../shared/agent-session-wire' +import type { AgentSessionRefusalReference } from '../../../../shared/agent-session-wire-refusals' const mocks = vi.hoisted( (): { applyEvent?: (event: AgentSessionSubscribeEvent) => void + applyError?: (message: string, refusal?: AgentSessionRefusalReference) => void started: number disposed: number } => ({ started: 0, disposed: 0 }) @@ -18,9 +20,11 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ vi.mock('./structured-agent-session-read-transport', () => ({ startStructuredAgentSessionReadTransport: (args: { applyEvent: (event: AgentSessionSubscribeEvent) => void + applyError: (message: string, refusal?: AgentSessionRefusalReference) => void }) => { mocks.started += 1 mocks.applyEvent = args.applyEvent + mocks.applyError = args.applyError return { captureHistoryReadGuard: () => () => false, dispose: () => { @@ -74,3 +78,20 @@ it("hears a hidden chat's review-reply receipt: the armed watch keeps the chat's // Fired, so the read it held is let go. expect(mocks.disposed).toBe(1) }) + +it('lets go of the read once the host says it holds no such chat', () => { + const settled = vi.fn() + mocks.disposed = 0 + watchStructuredReviewReplySettled('session-1', settled, { + holdRead: () => getStructuredAgentSessionReadOwner('session-1', { kind: 'local' }).activate() + }) + + mocks.applyError?.('no such chat', { + code: 'agent_session_identity_required', + details: { reason: 'recordMissing' } + }) + mocks.applyEvent?.(receipt()) + + expect(mocks.disposed).toBe(1) + expect(settled).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts index a6d3b6a66a5b..297470ff3e7b 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-owner.ts @@ -16,7 +16,10 @@ import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { NATIVE_CHAT_INITIAL_LIMIT, type NativeChatOlderPageResult } from './native-chat-pagination' import { startStructuredAgentSessionReadTransport } from './structured-agent-session-read-transport' -import { noticeStructuredReviewReplyReceipt } from '@/lib/structured-agent-session-review-reply-settled' +import { + dropStructuredReviewReplyWatchers, + noticeStructuredReviewReplyReceipt +} from '@/lib/structured-agent-session-review-reply-settled' import { createStructuredAgentSessionOlderPageReader, OLDER_PAGE_ANCHOR_ATTEMPTS @@ -196,7 +199,13 @@ function createReadOwner( apply({ type: 'event', event }) noticeStructuredReviewReplyReceipt(sessionId, event) }, - applyError: (message, refusal) => apply({ type: 'error', message, refusal }), + applyError: (message, refusal) => { + apply({ type: 'error', message, refusal }) + // The host holds no such chat: nothing it would say about a review reply is coming. + if (refusal?.details?.reason === 'recordMissing') { + dropStructuredReviewReplyWatchers(sessionId) + } + }, getCursor: () => snapshot.state.cursor, onHistoryReadInvalidated: invalidateOlderPages, hydrate: snapshot.state.epoch === null ? hydrate : undefined, diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts index eaee915ac685..fce253879aba 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts @@ -21,6 +21,9 @@ vi.mock('@/runtime/runtime-host-contact-regained', () => ({ import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' import { startStructuredAgentSessionReadTransport } from './structured-agent-session-read-transport' +import { agentJournalItemKey } from '../../../../shared/agent-session-journal-item-key' +import { agentSessionReviewReplyReceiptMessageId } from '../../../../shared/agent-session-review-reply' +import { watchStructuredReviewReplySettled } from '@/lib/structured-agent-session-review-reply-settled' type SubscribeAttempt = { closed: PromiseWithResolvers<{ unsubscribe: () => void }> @@ -609,4 +612,43 @@ describe('structured agent-session read transport unattached refusals', () => { startWithHydration(async () => undefined, vi.fn()).dispose() expect(mocks.watchHostContact).not.toHaveBeenCalled() }) + + it("leaves a review reply's receipt to the read owner, so one batch is noticed once", async () => { + vi.useFakeTimers() + try { + const settled = vi.fn() + const dispose = watchStructuredReviewReplySettled('session-a', settled) + const transport = startStructuredAgentSessionReadTransport({ + applyEvent: () => {}, + applyError: vi.fn(), + getCursor: () => null, + onHistoryReadInvalidated: () => undefined, + sessionId: 'session-a', + target + }) + await vi.advanceTimersByTimeAsync(0) + attempts[0].onEvent({ + type: 'batch', + sessionId: 'session-a', + batch: { + cursor: { epoch: 'epoch-a', sequence: 3 }, + items: [], + removedItemIds: [ + agentJournalItemKey({ + provider: 'orca', + clientMessageId: agentSessionReviewReplyReceiptMessageId('message-1') + }) + ], + submissions: [] + } + }) + await vi.advanceTimersByTimeAsync(100) + + expect(settled).not.toHaveBeenCalled() + transport.dispose() + dispose() + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts index 0186266ee013..a0a693e92a9c 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts @@ -1,4 +1,3 @@ -import { noticeStructuredReviewReplyReceipt } from '@/lib/structured-agent-session-review-reply-settled' import type { AgentJournalCursor } from '../../../../shared/agent-session-journal-types' import type { AgentSessionSubscribeEvent } from '../../../../shared/agent-session-wire' import { createStructuredAgentSessionEventCoalescer } from '../../../../shared/structured-agent-session-coalescer' @@ -104,7 +103,6 @@ export function startStructuredAgentSessionReadTransport(args: { const coalescer = createStructuredAgentSessionEventCoalescer((event) => { if (!shouldStopCoalescedEvent()) { args.applyEvent(event) - noticeStructuredReviewReplyReceipt(args.sessionId, event) } }) const reconnectScheduler = createReconnectScheduler({ diff --git a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx index d87e7d0d41c1..e8c1e9f82837 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx @@ -20,8 +20,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/launch-agent-in-new-tab', () => ({ launchAgentInNewTab: mocks.launchAgentInNewTab })) vi.mock('@/lib/focus-terminal-tab-surface', () => ({ focusTerminalTabSurface: vi.fn() })) vi.mock('@/lib/structured-agent-session-review-reply-support', () => ({ - structuredChatHostRunsReviewReplies: mocks.hostRunsReviewReplies, - structuredChatTargetForWorktree: () => ({ kind: 'environment', environmentId: 'paired-1' }) + structuredChatHostRunsReviewReplies: mocks.hostRunsReviewReplies })) vi.mock('@/components/native-chat/structured-agent-session-read-owner', () => ({ getStructuredAgentSessionReadOwner: (sessionId: string, target: unknown) => ({ @@ -216,7 +215,8 @@ describe('Resolve comments with AI', () => { mocks.launchAgentInNewTab.mockReturnValue({ surface: { kind: 'host-published' }, promptDeliveryResult: CARRIED, - structuredSettlement: Promise.resolve({ kind: 'structured', sessionId: 'paired-session' }) + structuredSettlement: Promise.resolve({ kind: 'structured', sessionId: 'paired-session' }), + structuredChatTarget: { kind: 'environment', environmentId: 'paired-1' } }) await expect(resolveCommentsWithAi(hook)).resolves.toBe(true) expect(model.fetchComments).not.toHaveBeenCalled() @@ -242,7 +242,8 @@ describe('Resolve comments with AI', () => { mocks.launchAgentInNewTab.mockReturnValue({ surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'session-1' }, promptDeliveryResult: CARRIED, - structuredSettlement: Promise.resolve({ kind: 'structured', sessionId: 'session-1' }) + structuredSettlement: Promise.resolve({ kind: 'structured', sessionId: 'session-1' }), + structuredChatTarget: { kind: 'local' } }) await expect(resolveCommentsWithAi(hook)).resolves.toBe(true) // The agent pushed: same PR, new head, and the panel's fetch was rebuilt for it. @@ -270,7 +271,8 @@ describe('Resolve comments with AI', () => { mocks.launchAgentInNewTab.mockReturnValue({ surface: { kind: 'local-agent-session', tabId: 'tab-1', sessionId: 'session-1' }, promptDeliveryResult: CARRIED, - structuredSettlement: Promise.resolve({ kind: 'structured', sessionId: 'session-1' }) + structuredSettlement: Promise.resolve({ kind: 'structured', sessionId: 'session-1' }), + structuredChatTarget: { kind: 'local' } }) await expect(resolveCommentsWithAi(hook)).resolves.toBe(true) diff --git a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts index 875be2ee736a..7ce826e8a614 100644 --- a/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts +++ b/src/renderer/src/components/right-sidebar/runSourceControlAgentActionStart.ts @@ -1,10 +1,7 @@ import type { AgentSessionReviewReply } from '../../../../shared/agent-session-review-reply' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' -import { - structuredChatHostRunsReviewReplies, - structuredChatTargetForWorktree -} from '@/lib/structured-agent-session-review-reply-support' +import { structuredChatHostRunsReviewReplies } from '@/lib/structured-agent-session-review-reply-support' import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { Repo } from '../../../../shared/repo-types' @@ -76,16 +73,18 @@ export type SourceControlAgentLaunched = { /** The chat a structured launch opened: this client's own from its surface, a paired host's from * the settlement that learned it. */ async function structuredChat( - result: NonNullable>, - worktreeId: string + result: NonNullable> ): Promise { + // The host the launch used, never one worked out again later from what the UI shows then. + const target = result.structuredChatTarget + if (!target) { + return undefined + } if (result.surface.kind === 'local-agent-session') { - return { sessionId: result.surface.sessionId, target: { kind: 'local' } } + return { sessionId: result.surface.sessionId, target } } const settled = await result.structuredSettlement?.catch(() => undefined) - return settled?.kind === 'structured' - ? { sessionId: settled.sessionId, target: structuredChatTargetForWorktree(worktreeId) } - : undefined + return settled?.kind === 'structured' ? { sessionId: settled.sessionId, target } : undefined } export async function runSourceControlAgentActionStart({ @@ -169,7 +168,7 @@ export async function runSourceControlAgentActionStart({ // which carries no review reply, so the panel writes after its paste. reviewReplyCarried = deliveryResult.reviewReplyCarried === true if (reviewReplyCarried) { - reviewReplyChat = await structuredChat(result, worktreeId) + reviewReplyChat = await structuredChat(result) } // A chat that holds its review reply sends it again itself; offering Start again here // would launch a second chat, and post the replies twice. diff --git a/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts b/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts index c4f2a02a4fe0..f7f7ac0f052b 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab-structured-route.ts @@ -1,3 +1,4 @@ +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import { adoptAgentSessionLaunchVerdict, type AgentSessionLaunchPlan @@ -16,6 +17,8 @@ type StructuredFromNewTab = { pasteDraftAfterLaunch: false structuredSettlement: Promise promptDeliveryResult?: Promise + /** The host this launch put the chat on: this client's own, or the paired server it asked. */ + structuredChatTarget: RuntimeClientTarget } /** @@ -55,6 +58,7 @@ export function launchStructuredAgentFromNewTab(args: { }, pasteDraftAfterLaunch: false, structuredSettlement: structured.structuredSettlement, + structuredChatTarget: { kind: 'local' }, ...(structured.promptDeliveryResult ? { promptDeliveryResult: structured.promptDeliveryResult } : {}) @@ -90,6 +94,7 @@ export function launchStructuredAgentFromNewTab(args: { surface: { kind: 'host-published' }, pasteDraftAfterLaunch: false, structuredSettlement: launch.settlement, + structuredChatTarget: paired.target, ...(launch.promptDeliveryResult ? { promptDeliveryResult: launch.promptDeliveryResult } : {}) } ) diff --git a/src/renderer/src/lib/launch-agent-in-new-tab.ts b/src/renderer/src/lib/launch-agent-in-new-tab.ts index d26c6250fa03..75e42f359dc6 100644 --- a/src/renderer/src/lib/launch-agent-in-new-tab.ts +++ b/src/renderer/src/lib/launch-agent-in-new-tab.ts @@ -1,3 +1,4 @@ +import type { RuntimeClientTarget } from '@/runtime/runtime-client-target' import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' import { useAppStore } from '@/store' import type { AgentStartupPlan } from '@/lib/tui-agent-startup' @@ -86,6 +87,8 @@ export type LaunchAgentInNewTabResult = { pasteDraftAfterLaunch: boolean /** `heldByChat` comes only from a structured chat's launch prompt. */ promptDeliveryResult?: Promise + /** Structured route only: the host the launch put the chat on, decided as it launched. */ + structuredChatTarget?: RuntimeClientTarget /** Structured route only: what the launch did once it settled. The call stays synchronous. */ structuredSettlement?: Promise } | null diff --git a/src/renderer/src/lib/structured-agent-session-paired-admission.test.ts b/src/renderer/src/lib/structured-agent-session-paired-admission.test.ts index 40f522bd2edf..4f43182307b0 100644 --- a/src/renderer/src/lib/structured-agent-session-paired-admission.test.ts +++ b/src/renderer/src/lib/structured-agent-session-paired-admission.test.ts @@ -27,6 +27,7 @@ import { useAppStore } from '@/store' import { adoptAgentSessionLaunchVerdict } from './agent-session-launch-plan' import { beginStructuredAgentSessionProvisionalLaunch } from './structured-agent-session-provisional-tab' import { beginDirectWorkItemStructuredLaunch } from './launch-work-item-direct-agent-routing' +import { launchStructuredAgentFromNewTab } from './launch-agent-in-new-tab-structured-route' import type { AiVaultSession } from '../../../shared/ai-vault-types' import { resumeAiVaultSessionInNewChat } from '@/components/right-sidebar/ai-vault-session-resume-in-chat-launch' import { getStructuredAgentLaunchStatus } from './structured-agent-session-launch-registry' @@ -218,4 +219,19 @@ describe('a structured chat launch on a paired server', () => { ) launch?.cancel() }) + + it('names the host it asked, so a later wait on the chat reads it there', () => { + mocks.createSupport.mockResolvedValue({ supported: true }) + + const launched = launchStructuredAgentFromNewTab({ + plan: pairedPlan(), + worktreeId: WORKTREE, + openTerminal: vi.fn() + }) + + expect(launched?.structuredChatTarget).toEqual({ + kind: 'environment', + environmentId: 'server-1' + }) + }) }) diff --git a/src/renderer/src/lib/structured-agent-session-review-reply-support.ts b/src/renderer/src/lib/structured-agent-session-review-reply-support.ts index 8040476779cc..38c39ae5ee27 100644 --- a/src/renderer/src/lib/structured-agent-session-review-reply-support.ts +++ b/src/renderer/src/lib/structured-agent-session-review-reply-support.ts @@ -13,7 +13,7 @@ import { } from '@/runtime/runtime-rpc-client' /** The host a structured chat in this workspace runs on; this client's own when none is named. */ -export function structuredChatTargetForWorktree(worktreeId: string): RuntimeClientTarget { +function structuredChatTargetForWorktree(worktreeId: string): RuntimeClientTarget { return ( structuredAgentSessionTargetForHost( resolveStructuredAgentSessionOwner(useAppStore.getState(), worktreeId) diff --git a/src/renderer/src/store/slices/tabs/structured-chat-close-owner.test.ts b/src/renderer/src/store/slices/tabs/structured-chat-close-owner.test.ts index 0c9e658cac14..a95b099e87f3 100644 --- a/src/renderer/src/store/slices/tabs/structured-chat-close-owner.test.ts +++ b/src/renderer/src/store/slices/tabs/structured-chat-close-owner.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Tab } from '../../../../../shared/tab-types' import { createTestStore, makeWorktree, seedStore } from '../store-test-helpers' -const mocks = vi.hoisted(() => ({ beginClose: vi.fn() })) +const mocks = vi.hoisted(() => ({ beginClose: vi.fn(), dropWatchers: vi.fn() })) vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() } @@ -10,6 +10,9 @@ vi.mock('sonner', () => ({ vi.mock('@/runtime/structured-agent-session-tab-retirement', () => ({ beginStructuredAgentSessionTabClose: mocks.beginClose })) +vi.mock('@/lib/structured-agent-session-review-reply-settled', () => ({ + dropStructuredReviewReplyWatchers: mocks.dropWatchers +})) // `repoId::path` names both checkouts: this machine's and the paired server's. const WORKTREE = 'repo-1::/work/app' @@ -58,6 +61,7 @@ function storeWith(tab: Tab): ReturnType { beforeEach(() => { mocks.beginClose.mockReset() + mocks.dropWatchers.mockReset() }) describe('closing a structured chat from outside its workspace', () => { @@ -81,5 +85,7 @@ describe('closing a structured chat from outside its workspace', () => { expect(mocks.beginClose).not.toHaveBeenCalled() expect(store.getState().unifiedTabsByWorktree[WORKTREE] ?? []).toEqual([]) + // No host to stop it on, but the chat is gone here, and so is a review reply's wait on it. + expect(mocks.dropWatchers).toHaveBeenCalledWith('chat-1') }) }) diff --git a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts index f4cd23a01d78..5dcc3e0ddb42 100644 --- a/src/renderer/src/store/slices/tabs/tabs-close-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-close-actions.ts @@ -1,3 +1,4 @@ +import { dropStructuredReviewReplyWatchers } from '@/lib/structured-agent-session-review-reply-settled' import type { TabsSlice, TabsSliceGet, TabsSliceSet } from './tabs-slice-contract' import { collapseGroupLayout } from './tabs-layout' import { @@ -73,6 +74,8 @@ export function createTabsCloseActions( console.warn('[structured-agent-session] close found no owning host', tab.entityId) } get().clearNativeChatLaunchDraft(structuredAgentSessionTabId(tab.entityId)) + // With or without a host to name, the chat is gone here, and so is any wait on it. + dropStructuredReviewReplyWatchers(tab.entityId) } // Why: on closing the active tab, walk the MRU stack to the previously-active tab; pickNextActiveTab falls back to the neighbor. const nextActiveTabId = diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-review-reply.test.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-review-reply.test.ts new file mode 100644 index 000000000000..c1ab6cbfeba6 --- /dev/null +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-review-reply.test.ts @@ -0,0 +1,41 @@ +import { expect, it, vi } from 'vitest' +import type { Tab } from '../../../../../../shared/tab-types' +import { watchStructuredReviewReplySettled } from '@/lib/structured-agent-session-review-reply-settled' +import { createStoreCascadesMockApi } from '../../store-cascades-test-harness' +import { createTestStore, makeWorktree, seedStore } from '../../store-test-helpers' +import { buildWorktreePurgeState } from './worktree-purge-state' + +const WORKTREE_ID = 'repo1::/path/wt1' + +createStoreCascadesMockApi() + +const chat: Tab = { + id: 'agent-session:chat-1', + entityId: 'chat-1', + groupId: 'group-1', + worktreeId: WORKTREE_ID, + contentType: 'agent-session', + agentSessionAgent: 'claude', + label: 'Claude Chat', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 +} + +it("lets go of a review reply's wait on a chat when its worktree is purged", () => { + const store = createTestStore() + seedStore(store, { + worktreesByRepo: { + repo1: [makeWorktree({ id: WORKTREE_ID, repoId: 'repo1', path: '/path/wt1' })] + }, + unifiedTabsByWorktree: { [WORKTREE_ID]: [chat] } + }) + const released = vi.fn() + const settled = vi.fn() + watchStructuredReviewReplySettled('chat-1', settled, { holdRead: () => released }) + + store.setState(buildWorktreePurgeState(store.getState(), [WORKTREE_ID])) + + expect(released).toHaveBeenCalledOnce() +}) diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts index ffa3cb6076dd..26a6cc23e44f 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts @@ -1,3 +1,4 @@ +import { dropStructuredReviewReplyWatchers } from '@/lib/structured-agent-session-review-reply-settled' import type { AppState } from '../../../types' import type { WorktreePurgeTarget, WorktreePurgeTargets } from '../../worktree-helpers' import { forgetHugeRepoWarningDismissalsForWorktrees } from '@/lib/source-control-huge-repo-warning-dismissals' @@ -55,6 +56,9 @@ export function buildWorktreePurgeState( } for (const worktreeId of worktreeIdSet) { for (const tab of s.unifiedTabsByWorktree[worktreeId] ?? []) { + if (tab.contentType === 'agent-session') { + dropStructuredReviewReplyWatchers(tab.entityId) + } // A retained launch here survived a reload, so its persisted record names its host. const owner = tab.contentType === 'agent-session' && !cancelledSessionIds.has(tab.entityId) From dc7f371aabdadd5e5bcdbb953f9338c7e2f1ce75 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:39:55 -0700 Subject: [PATCH 81/84] fix(native-chat): a failed review reply's line stays in sight after the agent's turn folds The host wrote the failure into the agent's live turn with no tone, so once the turn settled it folded under "Worked for Ns" and the person never learned the resolves and replies didn't happen. It is now written as Orca's other notes about the conversation are: outside any turn, with the error tone. The success tombstone still shows nothing. --- ...tured-agent-session-review-replies.test.ts | 10 ++++++++- ...structured-agent-session-review-replies.ts | 12 ++++++++--- .../native-chat-transcript-slots.test.ts | 21 +++++++++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts index a003ae88cf8c..7bfd117ed84c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts @@ -245,6 +245,11 @@ async function receipt(clientMessageId: string) { return { written: journal?.itemWritten(itemId) ?? false, line: line?.body } } +async function receiptItem(clientMessageId: string) { + const itemId = agentJournalItemKey(reviewReplyReceiptIdentity(clientMessageId)) + return (await host.journalSnapshot(SESSION)).items.find((item) => item.itemId === itemId) +} + async function settledReceipt(clientMessageId: string) { await eventually(async () => expect((await receipt(clientMessageId)).written).toBe(true)) return receipt(clientMessageId) @@ -382,8 +387,11 @@ describe('a launch prompt with a review reply', () => { expect(line).toMatchObject({ kind: 'status', text: "Orca couldn't mark the review comments sent with this message as being fixed. Resolve or reply to them yourself.", - failure: { kind: 'reviewReplyFailed', detail: { text: 'HTTP 401', audience: 'log' } } + failure: { kind: 'reviewReplyFailed', detail: { text: 'HTTP 401', audience: 'log' } }, + // Shown as a failure, and outside the agent's turn, so the turn folding never hides it. + tone: 'error' }) + expect((await receiptItem(id))?.turnScope).toEqual({ kind: 'thread' }) // The rest still went through. expect(writes()).toBe(3) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts index 002515034fc1..4e9cedbd34d6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.ts @@ -11,7 +11,10 @@ import { import { agentSessionFailureFact, providerDiagnostic } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity +} from '../../../shared/agent-session-journal-types' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { structuredAgentSessionConversationFence } from './structured-agent-session-provider-child' @@ -46,9 +49,12 @@ export async function writeStructuredAgentSessionReviewReplyReceipt( ...agentSessionFailureWords( agentSessionFailureFact('reviewReplyFailed', detail ? { detail } : {}), { surface: 'row' } - ) + ), + tone: 'error' }, - { fence, turnScope: journal.liveTurnScope() } + // About the review, not the agent's turn: a turn's rows fold once it ends, and the agent's + // answer would fold this line with them. + { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) } diff --git a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts index 7841e2de1908..d9efe25b1814 100644 --- a/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-transcript-slots.test.ts @@ -264,6 +264,27 @@ describe('a turn no message opened', () => { ]) }) + it('keeps an Orca failure about the conversation in sight when the turn around it folds', () => { + // A review reply's failed write lands mid-turn but belongs to no turn. + const messages = [ + text('u1', 'fix these', 'user'), + toolRun('work'), + failure('review-reply'), + text('a1', 'Done.') + ] + const slots = build(messages, { + turnKeys: ['u1', 'u1', undefined, 'u1'], + turnStatuses: { active: settled(3), completedByTurn: { u1: settled(3) } } + }) + expect(slots.find((slot) => slot.message.id === 'review-reply')?.folded).toBe(false) + // Scoped to the turn instead, the agent's answer after it would fold it away. + const inTurn = build(messages, { + turnKeys: ['u1', 'u1', 'u1', 'u1'], + turnStatuses: { active: settled(3), completedByTurn: { u1: settled(3) } } + }) + expect(inTurn.find((slot) => slot.message.id === 'review-reply')).toBeUndefined() + }) + it('folds an error the agent recovered from behind the answer that followed it', () => { const messages = [text('u1', 'go', 'user'), failure('retry'), text('a1', 'Done.')] const slots = build(messages, { From 5fa3b98b143bd953b43ea6e7792b92abf1a6c286 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:40:30 -0700 Subject: [PATCH 82/84] test(native-chat): the failed review reply's line is written while the agent's turn runs, and still lands outside it --- .../structured-agent-session-review-replies.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts index 7bfd117ed84c..53d4a30f0f92 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-review-replies.test.ts @@ -7,7 +7,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalSubmission +} from '../../../shared/agent-session-journal-types' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionReviewReply } from '../../../shared/agent-session-review-reply' import type { PRComment } from '../../../shared/github/comment-types' @@ -381,6 +384,13 @@ describe('a launch prompt with a review reply', () => { review.addRepoPRReviewCommentReply.mockResolvedValueOnce({ ok: false, error: 'HTTP 401' }) const id = await launchPrompt() await handedOver(id) + // The agent's turn is running when the writes fail, as it is once the agent took the message. + const journal = host.collaboratorsForTests().sessions.get(SESSION)?.journal + await journal?.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 999 }, + { kind: 'turn', turnId: 'turn-1', state: 'running', startedAt: 1 }, + { fence: fence(), turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) await accept(id) const { line } = await settledReceipt(id) From 436eeac2248b308d459b5f66dda038b6f71c24cd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:01:00 -0700 Subject: [PATCH 83/84] test(mobile): re-record the notes-not-delivered golden with the launch notice's new wording The phone's notice changed to "The notes weren't sent to the agent."; the golden still held the old "The agent started, but ..." toast. Re-recorded by the corpus script; no other golden moved. --- ...ate-terminal-diff-notes-not-delivered.json | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/mobile/rpc-foundation/goldens/session-create-terminal-diff-notes-not-delivered.json b/mobile/rpc-foundation/goldens/session-create-terminal-diff-notes-not-delivered.json index c658d151a50a..5b4365add7c3 100644 --- a/mobile/rpc-foundation/goldens/session-create-terminal-diff-notes-not-delivered.json +++ b/mobile/rpc-foundation/goldens/session-create-terminal-diff-notes-not-delivered.json @@ -57,14 +57,6 @@ "sessionTabs": [], "terminals": [] }, - "776182f604c7": { - "name": "toast", - "ordinal": 4, - "value": { - "durationMs": 2400, - "message": "The agent started, but the notes weren't sent." - } - }, "8f06d46b0825": { "name": "agent.launchReplay#1", "ordinal": 1, @@ -129,6 +121,14 @@ "status": "pending", "startedAt": 0 }, + "ac15d5ff2168": { + "name": "toast", + "ordinal": 4, + "value": { + "durationMs": 2400, + "message": "The notes weren't sent to the agent." + } + }, "ad660413fab0": { "name": "agent.launchReplay#1", "ordinal": 1, @@ -207,7 +207,7 @@ "create": "eb79a9b3682a" }, "state": "73449f52ee06", - "effects": ["6248a3dd0710", "776182f604c7"] + "effects": ["6248a3dd0710", "ac15d5ff2168"] } }, { @@ -220,7 +220,7 @@ "create": "eb79a9b3682a" }, "state": "73449f52ee06", - "effects": ["6248a3dd0710", "776182f604c7"] + "effects": ["6248a3dd0710", "ac15d5ff2168"] } } ] From 9217694ae932795e002d90f7cf6a5996cbd76538 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:01:01 -0700 Subject: [PATCH 84/84] fix(checks-panel): the missing-PR reply toast says what to do "Could not resolve the GitHub PR to reply on." read as an internal failure. It now reads "Couldn't find the GitHub PR to reply on. Reply to those comments yourself.", in every catalog. --- .../checks-panel/comment-resolution-review-reply.test.tsx | 2 +- .../checks-panel/use-checks-panel-ai-acknowledgement.tsx | 2 +- src/renderer/src/i18n/locales/en.json | 2 +- src/renderer/src/i18n/locales/es.json | 2 +- src/renderer/src/i18n/locales/fr.json | 2 +- src/renderer/src/i18n/locales/ja.json | 2 +- src/renderer/src/i18n/locales/ko.json | 2 +- src/renderer/src/i18n/locales/zh.json | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx index e8c1e9f82837..07f24df3e7cc 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/comment-resolution-review-reply.test.tsx @@ -339,7 +339,7 @@ describe('Resolve comments with AI', () => { }) ) expect(mocks.toastError).toHaveBeenCalledExactlyOnceWith( - 'Could not resolve the GitHub PR to reply on.' + "Couldn't find the GitHub PR to reply on. Reply to those comments yourself." ) expect(model.resolveReviewThread).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx index 73bf7852fe39..26a0ba745902 100644 --- a/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx +++ b/src/renderer/src/components/right-sidebar/checks-panel/use-checks-panel-ai-acknowledgement.tsx @@ -49,7 +49,7 @@ type ChecksPanelAiAcknowledgementInput = Pick< const cannotReplyOnPR = (): string => translate( 'auto.components.right.sidebar.ChecksPanel.7e4b2a19c0', - 'Could not resolve the GitHub PR to reply on.' + "Couldn't find the GitHub PR to reply on. Reply to those comments yourself." ) export function useChecksPanelAiAcknowledgement(model: ChecksPanelAiAcknowledgementInput) { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3428b0687dea..33b5cd4409d5 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -12389,7 +12389,7 @@ "pending": "Syncing…", "branch": "Sync Branch" }, - "7e4b2a19c0": "Could not resolve the GitHub PR to reply on.", + "7e4b2a19c0": "Couldn't find the GitHub PR to reply on. Reply to those comments yourself.", "430f1a62d4": "Could not resolve the selected thread on the host.", "updateReactionFailed": "Failed to update reaction.", "gitlabMoreActions": "More MR actions", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index de6d46ff302f..19b98e1a82ce 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -10569,7 +10569,7 @@ "pending": "Syncing…", "branch": "Sync Branch" }, - "7e4b2a19c0": "No se pudo resolver el PR de GitHub para responder.", + "7e4b2a19c0": "No se encontró el PR de GitHub en el que responder. Responde tú mismo a esos comentarios.", "430f1a62d4": "No se pudo resolver el hilo seleccionado en el host." }, "CreatePullRequestDialog": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 5b089d9e2293..c7ccd46514e0 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -12226,7 +12226,7 @@ "pending": "Synchronisation…", "branch": "Synchroniser la branche" }, - "7e4b2a19c0": "Impossible d'identifier la PR GitHub sur laquelle répondre.", + "7e4b2a19c0": "Impossible de trouver la PR GitHub sur laquelle répondre. Répondez vous-même à ces commentaires.", "430f1a62d4": "Impossible de résoudre le fil sélectionné côté hôte.", "updateReactionFailed": "Échec de la mise à jour de la réaction.", "gitlabMoreActions": "Plus d'actions MR", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 39251ddebc06..62d85d352a36 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -12124,7 +12124,7 @@ "pending": "同期中…", "branch": "ブランチを同期" }, - "7e4b2a19c0": "返信先の GitHub PR を特定できませんでした。", + "7e4b2a19c0": "返信先の GitHub PR が見つかりませんでした。これらのコメントにはご自身で返信してください。", "430f1a62d4": "選択したスレッドをホスト上で解決できませんでした。", "updateReactionFailed": "反応を更新できませんでした。", "gitlabMoreActions": "その他の MR 操作", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 985d2e089902..b100257ad87c 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -12124,7 +12124,7 @@ "pending": "Syncing…", "branch": "Sync Branch" }, - "7e4b2a19c0": "답글을 게시할 GitHub PR을 확인하지 못했습니다.", + "7e4b2a19c0": "답글을 게시할 GitHub PR을 찾지 못했습니다. 해당 댓글에 직접 답글을 남겨 주세요.", "430f1a62d4": "선택한 스레드를 호스트에서 해결하지 못했습니다.", "updateReactionFailed": "반응을 업데이트하지 못했습니다.", "gitlabMoreActions": "더 많은 MR 작업", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 0591697f858f..bbb509205638 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -12176,7 +12176,7 @@ "pending": "Syncing…", "branch": "Sync Branch" }, - "7e4b2a19c0": "无法确定要回复的 GitHub PR。", + "7e4b2a19c0": "找不到要回复的 GitHub PR。请自行回复这些评论。", "430f1a62d4": "无法在主机上解决选中的讨论串。", "updateReactionFailed": "无法更新反应。", "gitlabMoreActions": "更多 MR 动作",