diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts index e12acb3a12e..7f44c979ca2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-delivery-loop.ts @@ -26,7 +26,10 @@ import { oldestQueuedSubmission, recordStructuredAgentSessionStartFailure } from './structured-agent-session-start-failure-row' -import { failedProviderChildStart } from './structured-agent-session-provider-child' +import { + childEndFailsItsStart, + failedProviderChildStart +} from './structured-agent-session-provider-child' import { handOverSubmission } from './structured-agent-session-turns' export type StructuredAgentSessionDeliveryLoopDeps = { @@ -171,9 +174,9 @@ export class StructuredAgentSessionDeliveryLoop { if (!awaitedChild || (awaitedChild.phase === 'starting' && startFailure !== null)) { // The child waited on is gone, replaced by another, or settled its start without proving it. const ended = awaitedChild ? undefined : session.lastEndedChild - // A user's Stop is not a failure: the next step starts, or waits on, a child for what is - // queued. A host stop is: its cause is why the start did not land. - if (ended?.cause === 'user-stop') { + // A user's Stop or close is not a failure: the next step starts, or waits on, a child for + // what is queued. A host stop is: its cause is why the start did not land. + if (ended && !childEndFailsItsStart(ended.cause)) { return 'continue' } return this.fail(sessionId, { 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 86c0c363a1a..8f6c6a15caf 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 @@ -672,6 +672,22 @@ describe('how a stopped child ends the start its loop was waiting on', () => { expect(statusRows()).toEqual([]) }) + it('records no failure when the user closed the chat while its child started', async () => { + // The close's stop alone: a close that aborts after it leaves the conversation indexed. + const second = await stoppedWhileStarting(() => + host['serialize'](SESSION, () => + stopStructuredAgentSessionAgentUnderSerialize(host['lifetimeContext'](), SESSION, { + cause: 'user-close' + }) + ) + ) + + await settleLoop() + expect(conversation()?.lastEndedChild).toMatchObject({ cause: 'user-close' }) + expect(submission(second)?.dispatchState).not.toBe('rejected') + expect(statusRows()).toEqual([]) + }) + it('fails the start after a host stop, with the stop as the reason (R2)', async () => { const reason = 'Claude never finished starting, so Orca stopped it.' const second = await stoppedWhileStarting(() => diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts index 6c469e4dacc..a8e033c2be4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child.ts @@ -8,6 +8,7 @@ import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { + StructuredAgentSessionChildEndCause, StructuredAgentSessionEndedChild, StructuredAgentSessionHostSession, StructuredAgentSessionProviderChild, @@ -64,7 +65,22 @@ export function failedProviderChildStart( session: Pick ): StructuredAgentSessionEndedChild | null { const ended = session.lastEndedChild - return !session.child && ended?.duringStartup && ended.cause !== 'user-stop' ? ended : null + return !session.child && ended?.duringStartup && childEndFailsItsStart(ended.cause) ? ended : null +} + +/** Whether a child that ended this way, still starting, failed its start. A stop the user aimed at + * this chat is their cancellation, never a failure; a host's own stop is why the start did not land. */ +export function childEndFailsItsStart(cause: StructuredAgentSessionChildEndCause): boolean { + switch (cause) { + case 'user-stop': + case 'user-close': + return false + case 'exit': + case 'attach-failed': + case 'host-stop': + case 'evict': + return true + } } function matchingChild(