diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts index 60a16037880..a31b48c23bd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts @@ -139,12 +139,16 @@ it('refuses concurrent founding under a tab another chat already owns', async () expect(rig.adapter.acquire).not.toHaveBeenCalled() }) -it('validates adoption history before founding anything', async () => { +it('refuses history lost after discovery before founding anything', async () => { const params = await adoptedParams() - await writeFile(join(rig.root, 'rollout.jsonl'), 'not a transcript\n') + await rm(join(rig.root, 'rollout.jsonl')) expect(await rig.host.create(CALLER, params)).toMatchObject({ ok: false, - refusal: { code: 'agent_session_identity_required' } + refusal: { + code: 'agent_session_identity_required', + details: { reason: 'transcriptUnreadable' }, + createAttemptRefusedBeforeCommit: true + } }) expect(rig.store.listRecords()).toEqual([]) expect(rig.store.listOperationRows()).toEqual([]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts index 54bb3e6b4b8..d9c149c50af 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts @@ -153,7 +153,15 @@ async function foundConversation( ? { ok: true as const, items: null } : await prepareAdoptedTranscript(params) if (!prepared.ok) { - return prepared + const { refusal } = prepared + return { + ok: false, + refusal: + refusal.code === 'agent_session_identity_required' && + refusal.details?.reason === 'transcriptUnreadable' + ? { ...refusal, createAttemptRefusedBeforeCommit: true } + : refusal + } } const existing = context.sessions.get(sessionId) const opened = existing 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 6675ab4a1f0..5a984264521 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 @@ -8,6 +8,7 @@ import { setStructuredAgentSessionHost } from '../../../native-chat/agent-sessio import { recordingStructuredAgentSessionLogger } from '../../../native-chat/agent-session-wire/structured-agent-session-logger-test-support' import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' import { isDefinitiveAgentSessionCreateRefusal } from '../../../../shared/agent-session-definitive-refusal' +import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import type { OrcaRuntimeService } from '../../orca-runtime' import type { RpcResponse } from '../core' @@ -130,18 +131,20 @@ afterEach(() => { }) describe('a create refused before it commits', () => { - it('answers a code-carrying refusal as a definitive envelope', async () => { + it('answers missing history before commit as a definite failed attempt', async () => { const response = await create({ resolveStructuredAgentSessionCreateIntent: vi.fn(async () => { - throw new Error('structured_agent_session_unsupported') + throw agentSessionRefusalError('agent_session_identity_required', { + reason: 'transcriptNotFound' + }) }) }) - const refusal = refusalOf(response) - expect(refusal?.code).toBe('structured_agent_session_unsupported') - expect(refusal?.message).toContain('structured agent chat') - expect(refusal?.message).not.toContain('Codex') - expect(isDefinitiveAgentSessionCreateRefusal(refusal?.code)).toBe(true) + expect(refusalOf(response)).toMatchObject({ + code: 'agent_session_identity_required', + details: { reason: 'transcriptNotFound' }, + createAttemptRefusedBeforeCommit: true + }) expect(attach).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.ts b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.ts index 4038fe9e40d..9ca93701bea 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-precommit-refusal.ts @@ -75,6 +75,13 @@ export async function resolveUncommittedStructuredCreate( try { return await prepare() } catch (error) { - return { refusal: precommitRefusal(error) } + const refusal = precommitRefusal(error) + return { + refusal: + refusal.code === 'agent_session_identity_required' && + refusal.details?.reason === 'transcriptNotFound' + ? { ...refusal, createAttemptRefusedBeforeCommit: true } + : refusal + } } } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx index 3318059ce0e..06d1dcd6b76 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx +++ b/src/renderer/src/components/native-chat/structured-agent-session-status-bridge-unconfirmed-start.test.tsx @@ -328,7 +328,8 @@ describe('a chat whose start was never confirmed', () => { envelope: expect.objectContaining({ clientOperationId: `operation-${CLAUDE_SESSION}` }) }) }), - expect.any(Function) + expect.any(Function), + true ) expect(lifecycle()).toBeNull() expect(rows()).toEqual([]) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts index fee3133f0ae..ba29f3cd9d8 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts @@ -102,8 +102,14 @@ describe('resumeAiVaultSessionInNewChat', () => { ) }) - it('toasts a conflict reported by the eventual settlement', async () => { - const error = Object.assign(new Error('held'), { code: 'agent_session_conflict' }) + it.each([ + ['agent_session_conflict', 'Another chat is already holding this conversation.'], + [ + 'agent_session_identity_required', + "This conversation's history could not be loaded, so it cannot be resumed in chat." + ] + ])('toasts the eventual %s refusal', async (code, message) => { + const error = Object.assign(new Error('Host diagnostic'), { code }) mocks.beginStructuredAgentSessionProvisionalLaunch.mockReturnValue({ sessionId: 'session-1', tab: { id: 'agent-session:session-1' }, @@ -111,11 +117,7 @@ describe('resumeAiVaultSessionInNewChat', () => { }) await resumeAiVaultSessionInNewChat(session, 'codex', 'worktree-1', 'resume-click') - await vi.waitFor(() => - expect(mocks.toastError).toHaveBeenCalledWith( - 'Another chat is already holding this conversation.' - ) - ) + await vi.waitFor(() => expect(mocks.toastError).toHaveBeenCalledExactlyOnceWith(message)) }) it('keeps unknown outcomes silent for reconciliation', async () => { diff --git a/src/renderer/src/lib/launch-structured-agent-session.test.ts b/src/renderer/src/lib/launch-structured-agent-session.test.ts index 0b843806812..91f8b9839c6 100644 --- a/src/renderer/src/lib/launch-structured-agent-session.test.ts +++ b/src/renderer/src/lib/launch-structured-agent-session.test.ts @@ -499,23 +499,6 @@ describe('structured agent session launch', () => { expect(error).toBeInstanceOf(StructuredAgentSessionCreateUnknownOutcomeError) }) - it('preserves a definitive refusal code for the fallback path', async () => { - mockSupportedCreate(() => ({ - ok: false, - refusal: { - code: 'structured_agent_session_unsupported', - message: 'Structured chat is unavailable.' - } - })) - - const error = await launchStructuredAgentSession( - createStructuredAgentSessionLaunchIntent('workspace-unsupported', 'codex') - ).catch((caught: unknown) => caught) - - expect(error).toBeInstanceOf(StructuredAgentSessionCreateRefusalError) - expect(error).toMatchObject({ code: 'structured_agent_session_unsupported' }) - }) - it.each(['method_not_found', 'structured_agent_session_unsupported'])( 'turns an old-host %s error into a definitive transport refusal', async (code) => { diff --git a/src/renderer/src/lib/structured-agent-session-launch-create-call.ts b/src/renderer/src/lib/structured-agent-session-launch-create-call.ts index be0f3c6b853..613686742b4 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-create-call.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-create-call.ts @@ -78,7 +78,8 @@ export type StructuredLaunchHostSeedListener = (seedOptions: LaunchSeed) => void export async function launchStructuredAgentSession( intent: StructuredAgentSessionLaunchIntent, - onHostSeed?: StructuredLaunchHostSeedListener + onHostSeed?: StructuredLaunchHostSeedListener, + priorCreateOutcomeUnknown = false ): Promise { const hostSeed = await requireHostCreateSupport(intent) if (intent.target.kind !== 'local') { @@ -119,8 +120,18 @@ export async function launchStructuredAgentSession( if (!result.ok) { const { code, message, ownerVerdict } = result.refusal const refusal = readAgentSessionRefusalReference(result.refusal) - // A failed operation whose provider is proven gone is a failure a new operation may retry. - if (!isDefinitiveAgentSessionCreateRefusal(code) && ownerVerdict !== 'exited') { + const transcriptRefusedBeforeCommit = + !priorCreateOutcomeUnknown && + result.refusal.createAttemptRefusedBeforeCommit === true && + refusal?.code === 'agent_session_identity_required' && + (refusal.details?.reason === 'transcriptNotFound' || + refusal.details?.reason === 'transcriptUnreadable') + // A proven precommit transcript refusal lets a new operation retry. + if ( + !transcriptRefusedBeforeCommit && + !isDefinitiveAgentSessionCreateRefusal(code) && + ownerVerdict !== 'exited' + ) { // Keep the focus intent: the session may exist, and recovery still has to adopt it. throw new StructuredAgentSessionCreateUnknownOutcomeError(message, code, refusal) } diff --git a/src/renderer/src/lib/structured-agent-session-launch-errors.ts b/src/renderer/src/lib/structured-agent-session-launch-errors.ts index 2b94b0951a2..bf03c910570 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-errors.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-errors.ts @@ -12,10 +12,7 @@ export class StructuredAgentSessionCreateError extends Error { } } -/** - * The host proved it created nothing. The class itself is the verdict: - * `launchStructuredAgentSession` is the only place that decides it against the shared allowlist. - */ +/** A known create failure; the launch classifier keeps uncertain earlier attempts recoverable. */ export class StructuredAgentSessionCreateRefusalError extends StructuredAgentSessionCreateError { constructor( message: string, diff --git a/src/renderer/src/lib/structured-agent-session-launch-exited-owner.test.ts b/src/renderer/src/lib/structured-agent-session-launch-exited-owner.test.ts index 7ef9e0f9ac2..c90c22da7b6 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-exited-owner.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-exited-owner.test.ts @@ -46,6 +46,7 @@ vi.mock('@/lib/agent-catalog', () => ({ import { getStructuredAgentSessionLaunchLifecycle, retryStructuredAgentSessionLaunch, + shouldRetainStructuredAgentSessionLaunchTab, startStructuredAgentLaunch } from './structured-agent-session-launch' import { @@ -53,6 +54,7 @@ import { resetStructuredAgentLaunchRegistryForTests } from './structured-agent-session-launch-registry' import { resetStructuredAgentLaunchPersistenceForTests } from './structured-agent-session-launch-persistence' +import { structuredSessionNotices } from '@/components/native-chat/native-chat-structured-session-notices' const WORKTREE = 'wt-1' const EXIT_REASON = 'claude stream-json exited (code 1): stderr tail' @@ -62,6 +64,7 @@ const HOST_REFUSAL = { kind: 'refused', code: 'agent_session_operation_invalid' let replayVerdict: AgentSessionOwnerVerdict | undefined /** A host that answers the proven exit on the first call, not only on replay. */ let refuseFirstCall = false +let missingHistory: boolean | 'legacy' = false let failedOperation: string | null = null let publishedSessionId: string | null = null const createdOperations: string[] = [] @@ -79,7 +82,13 @@ function hostCreate(envelope: AgentSessionMutationEnvelope): unknown { throw new Error(EXIT_REASON) } const refusal: AgentSessionWireRefusal = { - code: 'agent_session_operation_invalid', + ...(missingHistory + ? { + code: 'agent_session_identity_required' as const, + details: { reason: 'transcriptNotFound' as const }, + ...(missingHistory === true ? { createAttemptRefusedBeforeCommit: true as const } : {}) + } + : { code: 'agent_session_operation_invalid' as const }), message: EXIT_REASON, ...(replayVerdict ? { ownerVerdict: replayVerdict } : {}) } @@ -106,6 +115,7 @@ beforeEach(() => { resetStructuredAgentLaunchRegistryForTests() replayVerdict = 'exited' refuseFirstCall = false + missingHistory = false failedOperation = null publishedSessionId = null createdOperations.length = 0 @@ -144,13 +154,26 @@ describe('structured launch after a host-failed create', () => { expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE, sessionId)).toBeNull() }) - it('fails on the first answer when the host already refuses with the proven exit', async () => { + it('retains a missing-history failed tab with Retry, then starts a new attempt', async () => { refuseFirstCall = true + missingHistory = true + replayVerdict = undefined const sessionId = launch() await settle(sessionId) expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE, sessionId)).toBe('failed') - // The host's message is for its log; the Retry line words the refusal. - expect(getStructuredAgentSessionLaunchFailure(WORKTREE, sessionId)).toEqual(HOST_REFUSAL) + expect(shouldRetainStructuredAgentSessionLaunchTab(WORKTREE, sessionId)).toBe(true) + expect( + structuredSessionNotices({ + launch: { + lifecycle: 'failed', + failure: getStructuredAgentSessionLaunchFailure(WORKTREE, sessionId), + retry: () => {} + }, + agentLabel: 'Codex', + sessionError: null, + composerError: null + }) + ).toMatchObject([{ text: 'Chat could not be started.', action: { label: 'Retry' } }]) expect(createdOperations).toEqual([failedOperation]) expect(retryStructuredAgentSessionLaunch(WORKTREE, sessionId)).toBe(true) await settle(sessionId) @@ -160,13 +183,20 @@ describe('structured launch after a host-failed create', () => { it.each([ ['the host could not prove the process gone', 'unverifiable'], - ['the host predates the verdict', undefined] - ] as const)('stays unknown when %s', async (_label, verdict) => { - replayVerdict = verdict - const sessionId = launch() - await settle(sessionId) + ['the host predates the missing-history proof', undefined, 'legacy'], + ['an earlier reply was lost before the missing-history refusal', undefined, true] + ] as const)( + 'stays unknown when %s', + async (_label, verdict, transcriptRefusal: boolean | 'legacy' = false) => { + replayVerdict = verdict + missingHistory = transcriptRefusal + const sessionId = launch() + await settle(sessionId) - expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE, sessionId)).toBe('visibility-unknown') - expect(createdOperations.every((operation) => operation === failedOperation)).toBe(true) - }) + expect(getStructuredAgentSessionLaunchLifecycle(WORKTREE, sessionId)).toBe( + 'visibility-unknown' + ) + expect(createdOperations.every((operation) => operation === failedOperation)).toBe(true) + } + ) }) diff --git a/src/renderer/src/lib/structured-agent-session-launch-recovery.ts b/src/renderer/src/lib/structured-agent-session-launch-recovery.ts index 5badfaf0496..d41ae102f05 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-recovery.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-recovery.ts @@ -74,7 +74,7 @@ export async function recoverPublishedSessionReceipt( ) : undefined if (state.intent.params.firstMessage && !firstMessage) { - return launchStructuredAgentSession(state.intent, state.onHostSeed) + return launchStructuredAgentSession(state.intent, state.onHostSeed, true) } if (state.intent.createMessageSupport === true) { publishStructuredAgentSessionCreateHydration(state.intent, history.page, fence) @@ -88,7 +88,7 @@ async function retrySameIntent( ): Promise { throwIfLaunchCancelled(state) try { - const receipt = await launchStructuredAgentSession(state.intent, state.onHostSeed) + const receipt = await launchStructuredAgentSession(state.intent, state.onHostSeed, true) throwIfLaunchCancelled(state) if (state.intent.createMessageSupport !== true) { await verifyPublishedSession(state) diff --git a/src/renderer/src/lib/structured-agent-session-launch-reload.test.ts b/src/renderer/src/lib/structured-agent-session-launch-reload.test.ts index 8922deabfe1..4ab7ee061d6 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-reload.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-reload.test.ts @@ -178,7 +178,8 @@ describe('structured agent launch reload recovery', () => { }) }) }), - expect.any(Function) + expect.any(Function), + true ) }) }) diff --git a/src/shared/agent-session-wire-refusals.ts b/src/shared/agent-session-wire-refusals.ts index 8fd1602377b..414dfb93c07 100644 --- a/src/shared/agent-session-wire-refusals.ts +++ b/src/shared/agent-session-wire-refusals.ts @@ -62,6 +62,8 @@ export type AgentSessionRefusalOf = { message: string /** Absent from older hosts. */ details?: AgentSessionRefusalDetails + /** This create attempt refused before commit; earlier attempts may still have committed. */ + createAttemptRefusedBeforeCommit?: true } & AgentSessionLegacyRefusalFields }[C]