diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts index 6eda6b63587..57c7044ce95 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts @@ -42,6 +42,18 @@ export type StructuredAgentSessionRestartCandidateReader = ( options?: StructuredAgentSessionRestartCandidateOptions ) => StructuredAgentSessionResumeCandidate[] +/** The newest user message in a live session's journal, the same fact the predicate compares + * against a marker. Undefined when the session is not readable here, which decides nothing. */ +export function liveStructuredAgentSessionLatestUserItemId( + sessions: ReadonlyMap, + sessionId: string +): string | null | undefined { + const session = sessions.get(sessionId) + return session + ? (latestStructuredAgentSessionUserItem(session.journal.snapshot().items)?.itemId ?? null) + : undefined +} + export function createStructuredAgentSessionRestartCandidateReader(deps: { /** The host's LIVE session map — the only honest answer to "was this actually working". */ sessions: ReadonlyMap diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts index e7b1b13ac3c..2d558f8f028 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts @@ -1,7 +1,13 @@ import { expect, it, vi } from 'vitest' import { marker, SESSION } from './structured-agent-session-restart-resume-test-harness' +import { + AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, + AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, + AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE +} from '../../../shared/agent-session-restart-continuation' import { continueStructuredAgentSessionAfterRestart, + RestartContinuationSupersededError, type StructuredAgentSessionContinuationDeps } from './structured-agent-session-restart-continuation' @@ -38,35 +44,53 @@ it('reports an accepted continuation and records its note', async () => { expect(deps.note).toHaveBeenCalledOnce() }) +const UNCONFIRMED = [SESSION, AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, 'warning'] +const REFUSED = [SESSION, AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, 'error'] +const NOT_CONNECTED = [SESSION, AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE, 'error'] + it.each([ - ['pending', { sessionId: SESSION, outcome: 'pending' }], - ['unknown', { sessionId: SESSION, outcome: 'unknown' }], - ['rejected', { sessionId: SESSION, outcome: 'refused', reason: 'provider_refused' }] + ['pending', { sessionId: SESSION, outcome: 'pending' }, UNCONFIRMED], + ['unknown', { sessionId: SESSION, outcome: 'unknown' }, UNCONFIRMED], + ['rejected', { sessionId: SESSION, outcome: 'refused', reason: 'provider_refused' }, REFUSED] ] as const)( - 'preserves a %s settlement without recording a success note', - async (settled, expected) => { + 'preserves a %s settlement and notes it in the chat instead of the success note', + async (settled, expected, note) => { const deps = dependencies(settled) await expect( continueStructuredAgentSessionAfterRestart(deps, SESSION, marker()) ).resolves.toEqual(expected) - expect(deps.note).not.toHaveBeenCalled() + expect(deps.note).toHaveBeenCalledExactlyOnceWith(...note) } ) -it('reports a send refusal without waiting for settlement', async () => { +it('notes a superseded continuation in the chat and still reports the refusal', async () => { const deps = dependencies('accepted') - deps.send.mockResolvedValue({ ok: false, refusal: { code: 'agent_session_conflict' } }) + deps.send.mockRejectedValue(new RestartContinuationSupersededError()) + + await expect( + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker()) + ).rejects.toBeInstanceOf(RestartContinuationSupersededError) + expect(deps.note).toHaveBeenCalledExactlyOnceWith(...REFUSED) +}) + +// An ownership refusal would meet the user's own message too, so the note gives no advice to send one. +it.each([ + ['agent_session_conflict', NOT_CONNECTED], + ['agent_session_operation_invalid', REFUSED] +] as const)('reports a %s send refusal without waiting for settlement', async (code, note) => { + const deps = dependencies('accepted') + deps.send.mockResolvedValue({ ok: false, refusal: { code } }) await expect( continueStructuredAgentSessionAfterRestart(deps, SESSION, marker()) ).resolves.toEqual({ sessionId: SESSION, outcome: 'refused', - reason: 'agent_session_conflict' + reason: code }) expect(deps.awaitSettlement).not.toHaveBeenCalled() - expect(deps.note).not.toHaveBeenCalled() + expect(deps.note).toHaveBeenCalledExactlyOnceWith(...note) }) it('reports an unattached chat without sending', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts index 22dfcdff882..67a3d61ee54 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.ts @@ -6,11 +6,19 @@ // agent to verify its last action before repeating it, and the launch toast reports what happened. import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' +import type { + AgentSessionMutationEnvelope, + AgentSessionMutationResult, + AgentSessionSendResult +} from '../../../shared/agent-session-wire' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { AGENT_SESSION_RESTART_CONTINUATION_MESSAGE, - AGENT_SESSION_RESTART_CONTINUATION_NOTE + AGENT_SESSION_RESTART_CONTINUATION_NOTE, + AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, + AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, + AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE } from '../../../shared/agent-session-restart-continuation' import { AgentSessionPreDispatchError } from './structured-agent-session-operation-settlement' import { createHash } from 'node:crypto' @@ -34,6 +42,87 @@ export type StructuredAgentSessionContinuationOutcome = { reason?: string } +/** The slice of the host one continuation needs. Structural so this module never imports the host. */ +export type StructuredAgentSessionContinuationHost = { + sessions: ReadonlyMap + send: (input: { + envelope: AgentSessionMutationEnvelope + body: AgentJournalMessageItem + beforeRun?: () => void + }) => Promise> + awaitSendSettlement: ( + sessionId: string, + clientMessageId: string + ) => Promise<{ value: AgentSessionSendResult } | undefined> + onNoteFailed: (sessionId: string, error: unknown) => void + publish: (sessionId: string, journal: AgentSessionJournal) => void + now: () => number + /** Whether the marker still describes resumable work, with the continuation's own submission + * set aside. Re-asked right before dispatch, so newer user work refuses the send. */ + stillResumable: (marker: AgentSessionResumeMarker, pendingContinuationId: string) => boolean +} + +/** Binds one continuation to the host: the superseded check before dispatch, the settlement + * waiter for the verdict, and the journal note that attributes the send to Orca. */ +export function restartContinuationDeps( + host: StructuredAgentSessionContinuationHost, + marker: AgentSessionResumeMarker +): StructuredAgentSessionContinuationDeps { + return { + currentFence: (sessionId) => host.sessions.get(sessionId)?.fence ?? null, + send: (input) => + host.send({ + ...input, + beforeRun: () => { + if (!host.stillResumable(marker, input.envelope.clientOperationId)) { + throw new RestartContinuationSupersededError() + } + } + }), + awaitSettlement: async (sessionId, clientMessageId) => + (await host.awaitSendSettlement(sessionId, clientMessageId))?.value.submission, + onNoteFailed: host.onNoteFailed, + note: restartNoteWriter(host) + } +} + +/** The note for a reattach that failed before any continuation was attempted. */ +export function noteRestartReattachFailed( + host: StructuredAgentSessionContinuationHost, + sessionId: string +): Promise { + return noteNotContinued( + { note: restartNoteWriter(host), onNoteFailed: host.onNoteFailed }, + sessionId, + 'not-connected' + ) +} + +/** Writes a host-authored status note into the chat and publishes it to open panes. */ +function restartNoteWriter( + host: Pick +): StructuredAgentSessionContinuationDeps['note'] { + return async (sessionId, text, tone) => { + const session = host.sessions.get(sessionId) + if (!session) { + return + } + await session.journal.appendItem( + { provider: 'orca', clientMessageId: `restart-continuation:${sessionId}:${host.now()}` }, + { kind: 'status', text, ...(tone ? { tone } : {}) }, + { fence: session.fence } + ) + host.publish(sessionId, session.journal) + } +} + +/** Refusals the user's own message would meet as well; the restart list says to retry these. */ +const OWNERSHIP_REFUSALS = new Set([ + 'agent_session_conflict', + 'agent_session_ownership_unknown', + 'execution_owner_reconciling' +]) + /** Only this pre-dispatch failure proves a thrown send did not deliver. */ export class RestartContinuationSupersededError extends AgentSessionPreDispatchError { constructor() { @@ -109,8 +198,9 @@ export type StructuredAgentSessionContinuationDeps = { sessionId: string, clientMessageId: string ) => Promise<{ dispatchState?: string; reason?: string | null } | undefined> - /** Records the host-authored journal note that marks this send as Orca's, not the user's. */ - note: (sessionId: string, text: string) => Promise + /** Records a host-authored journal note: that this send was Orca's, not the user's, or that the + * chat did not carry on. `tone` is a display hint older clients render as plain text. */ + note: (sessionId: string, text: string, tone?: 'error' | 'warning') => Promise /** Reports a note that could not be written. The note is best effort, but its failure is not * allowed to be silent — a swallowed append is how this regressed unnoticed once already. */ onNoteFailed: (sessionId: string, error: unknown) => void @@ -127,6 +217,54 @@ export async function continueStructuredAgentSessionAfterRestart( deps: StructuredAgentSessionContinuationDeps, sessionId: string, marker: AgentSessionResumeMarker +): Promise { + let result: StructuredAgentSessionContinuationOutcome + try { + result = await sendContinuation(deps, sessionId, marker) + } catch (error) { + await noteNotContinued(deps, sessionId, 'refused') + throw error + } + if (result.outcome !== 'continued') { + await noteNotContinued( + deps, + sessionId, + result.outcome !== 'refused' + ? 'unconfirmed' + : OWNERSHIP_REFUSALS.has(result.reason ?? '') + ? 'not-connected' + : 'refused' + ) + } + return result +} + +/** The chat itself carries the failure, so it survives the toast, a dismissed record and a restart, + * and the user's next message is what moves past it. */ +async function noteNotContinued( + deps: Pick, + sessionId: string, + outcome: 'refused' | 'not-connected' | 'unconfirmed' +): Promise { + try { + await (outcome === 'unconfirmed' + ? deps.note(sessionId, AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, 'warning') + : deps.note( + sessionId, + outcome === 'refused' + ? AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE + : AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE, + 'error' + )) + } catch (error) { + deps.onNoteFailed(sessionId, error) + } +} + +async function sendContinuation( + deps: StructuredAgentSessionContinuationDeps, + sessionId: string, + marker: AgentSessionResumeMarker ): Promise { const fence = deps.currentFence(sessionId) if (fence === null) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts new file mode 100644 index 00000000000..82d65253f15 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts @@ -0,0 +1,230 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' +import { + AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, + AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, + AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE +} from '../../../shared/agent-session-restart-continuation' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { latestStructuredAgentSessionUserItem } from '../../../shared/structured-agent-session-projection' +import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' +import { + interruptedRestart, + statusNotes +} from './structured-agent-session-restart-interruption-test-harness' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestMessage +} from './structured-agent-session-host-test-data' + +// Which outcomes of a restart action become a failure the user is shown, and what retires one. + +afterEach(() => vi.restoreAllMocks()) + +function providerEvents(acquire: Awaited>['acquire']) { + const events = acquire.mock.calls[0]?.[0].events + if (!events) { + throw new Error('missing resumed provider event sink') + } + return events +} + +// The reported case: after the reattach the provider replays a queued message, which the chat +// journals as a newer user turn, so the continuation is refused just before dispatch. +it('files a continuation superseded by a replayed message, lists it and says so in the chat', async () => { + const { host, acquire, dispatch, root } = await interruptedRestart() + await host.restartResume.list() + await host.hold(SESSION, 'pane') + const events = providerEvents(acquire) + const append = AgentSessionJournal.prototype.appendSubmission + vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission').mockImplementationOnce( + async function (this: AgentSessionJournal, input) { + const cursor = await append.call(this, input) + events.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'replayed-turn', ordinal: 1 }, + hostTestMessage('A queued notification the provider replayed') + ) + return cursor + } + ) + + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(dispatch).not.toHaveBeenCalled() + expect(result.resumed).toMatchObject([ + { outcome: 'refused', reason: 'agent_session_restart_work_superseded' } + ]) + const failure = { + sessionId: SESSION, + outcome: 'refused', + reason: 'agent_session_restart_work_superseded' + } + expect(result.failed).toMatchObject([failure]) + expect(await host.restartResume.listFailures()).toMatchObject([failure]) + expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toHaveLength(1) + expect(statusNotes(host)).toContainEqual({ + text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, + tone: 'error' + }) + host.release(SESSION, 'pane') +}) + +// Nothing was attempted and nothing is owed: the chat moved on by itself between listing and acting. +it('files nothing for a chat that finished on its own before its attempt, and spends the offer', async () => { + const { host, acquire, root } = await interruptedRestart() + await host.restartResume.list() + await host.hold(SESSION, 'pane') + const events = providerEvents(acquire) + const admit = StructuredAgentSessionResumeAdmission.prototype.run + vi.spyOn(StructuredAgentSessionResumeAdmission.prototype, 'run').mockImplementationOnce( + async function (this, ...args) { + events.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'interrupted-turn', ordinal: 1 }, + { kind: 'turn', turnId: 'interrupted-turn', state: 'completed' }, + { lifecycle: true } + ) + await host.flushStreamedEvents(SESSION) + return admit.apply(this, args) + } + ) + + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + + expect(result.resumed).toMatchObject([{ reason: 'agent_session_resume_not_eligible' }]) + expect(result.failed).toEqual([]) + const capsule = new AgentSessionRecoveryCapsule(root) + expect(await capsule.listFailed(NOW)).toEqual([]) + expect(await capsule.list(NOW)).toEqual([]) + expect(statusNotes(host)).toEqual([]) + host.release(SESSION, 'pane') +}) + +it.each(['resume', 'continueAfterRestart'] as const)( + 'says so in the chat when the reattach itself fails (%s)', + async (action) => { + const { host, acquire } = await interruptedRestart() + await host.restartResume.list() + acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) + + await host.restartResume[action]([SESSION], 'modal') + + expect(await host.restartResume.listFailures()).toMatchObject([ + { sessionId: SESSION, outcome: 'refused' } + ]) + // The fix depends on why it failed, which the dialog explains; "send a message" would not work. + expect(statusNotes(host)).toEqual([ + { text: AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE, tone: 'error' } + ]) + } +) + +/** A continuation the provider accepted whose settlement could not be written: filed unconfirmed. */ +async function unconfirmedContinuation() { + const state = await interruptedRestart() + const { host, store } = state + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await host.restartResume.list() + await host.hold(SESSION, 'pane') + const settle = store.recordOperationOutcome.bind(store) + vi.spyOn(store, 'recordOperationOutcome').mockImplementation(async (input) => { + if (input.outcome.status === 'succeeded') { + throw new Error('operation outcome could not be persisted') + } + return settle(input) + }) + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'unconfirmed' }]) + expect(statusNotes(host)).toContainEqual({ + text: AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, + tone: 'warning' + }) + const continuation = host.journalSnapshot(SESSION).submissions.at(-1) + const providerItemId = continuation?.providerItemId + if (!continuation || !providerItemId) { + throw new Error('missing accepted continuation') + } + return { ...state, continuation, providerItemId, events: providerEvents(state.acquire) } +} + +// The warning asked the user to check the agent's reply; a turn opened by the continuation's own +// message is that reply starting, however the provider names the message. +it.each(['submission key', 'provider key'] as const)( + 'retires an unconfirmed failure once the continuation opens a turn (%s)', + async (naming) => { + const { host, root, continuation, providerItemId, events } = await unconfirmedContinuation() + events.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'continued-turn', ordinal: 1 }, + { + kind: 'turn', + turnId: 'continued-turn', + state: 'running', + userItemId: + naming === 'submission key' + ? agentJournalSubmissionKey(continuation.clientMessageId) + : providerItemId + }, + { lifecycle: true } + ) + await host.flushStreamedEvents(SESSION) + + expect(await host.restartResume.listFailures()).toEqual([]) + await vi.waitFor(async () => { + expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toEqual([]) + }) + host.release(SESSION, 'pane') + } +) + +it('keeps an unconfirmed failure while the newest turn is not the continuation’s', async () => { + const { host, events } = await unconfirmedContinuation() + // Provider output opened this turn, keyed by its own row rather than by any user message. + events.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'provider-turn', ordinal: 1 }, + { kind: 'turn', turnId: 'provider-turn', state: 'running', userItemId: 'provider-turn-row' }, + { lifecycle: true } + ) + await host.flushStreamedEvents(SESSION) + + expect(await host.restartResume.listFailures()).toMatchObject([{ outcome: 'unconfirmed' }]) + host.release(SESSION, 'pane') +}) + +// Nothing journaled the continuation, so the newest user message is still the interrupted one and +// the turn it opened is the interrupted work reporting in, not the agent carrying on. +it('keeps an unconfirmed failure whose continuation was never journaled while the interrupted turn runs', async () => { + const { host, acquire, store, marker } = await interruptedRestart('submission', false) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + await host.restartResume.list() + await host.hold(SESSION, 'pane') + const events = providerEvents(acquire) + events.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'original-turn', ordinal: 1 }, + hostTestMessage('Perform the original task') + ) + await host.flushStreamedEvents(SESSION) + // A send that throws before recording anything cannot be proven undelivered. + vi.spyOn(store, 'admitMutationOperation').mockRejectedValueOnce(new Error('store unavailable')) + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'unconfirmed' }]) + const submissions = host.journalSnapshot(SESSION).submissions + const original = submissions[0]?.providerItemId + if (submissions.length !== 1 || !original) { + throw new Error('expected only the original submission, accepted by the provider') + } + expect(latestStructuredAgentSessionUserItem(host.journalSnapshot(SESSION).items)?.itemId).toBe( + marker?.latestUserItemId + ) + + events.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'original-turn', ordinal: 2 }, + { kind: 'turn', turnId: 'original-turn', state: 'running', userItemId: original }, + { lifecycle: true } + ) + await host.flushStreamedEvents(SESSION) + + expect(await host.restartResume.listFailures()).toMatchObject([{ outcome: 'unconfirmed' }]) + host.release(SESSION, 'pane') +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts new file mode 100644 index 00000000000..93941b7b30c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-ledger.ts @@ -0,0 +1,313 @@ +// What became of the offers an action spent, kept for the surfaces that must still name them. +// +// The toast that reports a chat Orca could not carry on is gone in seconds and the reattach spends +// the offer, so without this record nothing durable would point at the chat the user has to +// continue by hand. The capsule holds the record; this decides what goes in and when it leaves. +// +// Whether a record is still current is derived, never cached: it is current only while the chat's +// newest user message is the one it had when the failure was filed. The user's own send, from any +// client, therefore retires it without a hook on the send path. + +import type { + AgentSessionRecoveryCapsule, + AgentSessionResumeFailureInput, + AgentSessionResumeFailureRecord +} from '../../runtime/agent-session-recovery-capsule' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionResumeFailureOutcome, + AgentSessionResumeMarker +} from '../../../shared/agent-session-resume-marker' +import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal-types' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { isRootAgentJournalItem } from '../../../shared/agent-session-journal-producer' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { latestStructuredAgentSessionUserItem } from '../../../shared/structured-agent-session-projection' +import { normalizeOptionalField } from '../../../shared/agent-status-field-normalization' +import { AGENT_MODEL_MAX_LENGTH } from '../../../shared/agent-status-types' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { adapterSupportsRecord } from './structured-agent-session-provider-support' +import { + liveStructuredAgentSessionLatestUserItemId, + type StructuredAgentSessionRestartJournalSource +} from './structured-agent-session-restart-candidates' +import type { StructuredAgentSessionContinuationOutcome } from './structured-agent-session-restart-continuation' +import type { + StructuredAgentSessionResumeCandidate, + StructuredAgentSessionResumeFailure +} from './structured-agent-session-restart-resume-set' +import { + STRUCTURED_AGENT_SESSION_RESUME_NOT_ELIGIBLE, + type StructuredAgentSessionResumeOutcome +} from './structured-agent-session-restart-resume-runner' + +type FailureCapsule = Pick< + AgentSessionRecoveryCapsule, + | 'listFailed' + | 'completeResume' + | 'failResume' + | 'rollbackResume' + | 'dismiss' + | 'clearAll' + | 'forgetFailures' +> + +/** What a failure is filed against: the chat's newest user message as its own attempt ended. Kept + * per chat rather than read at settlement, because the rest of a batch can take a while and a + * message the user sends meanwhile answers the failure rather than belongs to it. */ +export type StructuredAgentSessionRestartAttempts = { + observe: (sessionId: string) => void + latestUserItemId: (sessionId: string) => string | null +} + +export type StructuredAgentSessionRestartFailureLedger = { + /** The stored records, current or not. */ + read: () => Promise + /** The current records as rows a surface can show; sessions this host no longer holds are left + * out, and records the chat has since superseded are dropped and pruned. */ + list: () => Promise + /** One action's attempts; a chat never observed after an attempt falls back to its reserved + * marker. */ + attempts: ( + markers: ReadonlyMap + ) => StructuredAgentSessionRestartAttempts + /** Settles one operation's reservations: the agent carried on, or the failure is filed. Rows the + * operation still owns after that are reopened. */ + settle: ( + operationId: string, + outcomes: readonly StructuredAgentSessionResumeOutcome[], + action: { + candidates: readonly StructuredAgentSessionResumeCandidate[] + attempts: StructuredAgentSessionRestartAttempts + /** How a reattached session's action ended; null when the agent carried on. Reattaching alone + * is not the whole action, so the runner's own outcome cannot decide this. */ + failureAfterResume: (sessionId: string) => AgentSessionResumeFailureOutcome | null + failureReason: (sessionId: string) => string + } + ) => Promise + /** Named sessions forget their offer or failure; unnamed, every durable record goes. */ + dismiss: ( + sessionIds: readonly string[] | undefined, + beforeClearAll: () => void + ) => Promise +} + +/** Which continuation outcomes count as the agent not carrying on, and how each is filed. */ +export function continuationFailureOutcome( + outcome: StructuredAgentSessionContinuationOutcome['outcome'] +): AgentSessionResumeFailureOutcome | null { + return outcome === 'continued' ? null : outcome === 'refused' ? 'refused' : 'unconfirmed' +} + +/** Whether the chat itself has moved past a failure: a newer user message, or, for a delivery + * nobody confirmed, a turn the continuation's own message opened. */ +function failureAnsweredByChat( + failure: AgentSessionResumeFailureRecord, + snapshot: AgentJournalSnapshot +): boolean { + const latest = latestStructuredAgentSessionUserItem(snapshot.items)?.itemId ?? null + if (latest !== failure.latestUserItemId) { + return true + } + // Only when the continuation's message was journaled: otherwise the newest user message is the + // interrupted one, whose own turn proves nothing about the continuation. + return ( + failure.outcome === 'unconfirmed' && + latest !== null && + latest !== failure.marker.latestUserItemId && + newestTurnUserItemId(snapshot) === latest + ) +} + +/** The user message that opened the newest root turn, resolved through a provider key the send was + * accepted under. */ +function newestTurnUserItemId(snapshot: AgentJournalSnapshot): string | null { + for (let index = snapshot.items.length - 1; index >= 0; index -= 1) { + const item = snapshot.items[index] + const turn = isRootAgentJournalItem(item) ? readAgentJournalTurn(item?.body) : null + if (!turn) { + continue + } + const key = turn.userItemId + if (key === undefined) { + return null + } + const accepted = snapshot.submissions.find((entry) => entry.providerItemId === key) + return accepted ? agentJournalSubmissionKey(accepted.clientMessageId) : key + } + return null +} + +export function createStructuredAgentSessionRestartFailureLedger(deps: { + capsule?: FailureCapsule + sessions: ReadonlyMap + /** Makes a persisted chat's journal readable here, as listing an offer does. */ + reveal: (sessionId: string) => Promise + getRecord: (sessionId: string) => AgentSessionRecord | null + adapter: StructuredAgentSessionAdapter + /** The predicate a retry applies to the failure's marker. */ + retryable: (marker: AgentSessionResumeMarker) => boolean + now: () => number + /** The capsule's single mutation lane, shared with the offer's own operations. */ + enqueue: (operation: () => Promise) => Promise +}): StructuredAgentSessionRestartFailureLedger { + const read = async (): Promise => { + try { + return (await deps.capsule?.listFailed(deps.now())) ?? [] + } catch { + // Recovery is advisory; a malformed capsule must not make ordinary chat actions unusable. + console.warn('[structured-agent-session] reading recovery capsule failed') + return [] + } + } + + const toRow = ( + failure: AgentSessionResumeFailureRecord + ): StructuredAgentSessionResumeFailure[] => { + const record = deps.getRecord(failure.marker.sessionId) + if (!record || !adapterSupportsRecord(deps.adapter, record)) { + return [] + } + const model = normalizeOptionalField(record.options?.model, AGENT_MODEL_MAX_LENGTH) + return [ + { + sessionId: failure.marker.sessionId, + workspaceId: record.location.workspaceId, + agent: record.provider, + work: failure.marker.work, + trigger: failure.marker.trigger, + recordedAt: failure.marker.recordedAt, + latestPrompt: failure.latestPrompt, + executionHostId: record.location.executionHostId, + workspaceKind: record.location.workspaceKind, + ...(model === undefined ? {} : { model }), + failedAt: failure.failedAt, + outcome: failure.outcome, + reason: failure.reason, + retryable: deps.retryable(failure.marker) + } + ] + } + + const list = async (): Promise => { + const current: AgentSessionResumeFailureRecord[] = [] + const superseded: AgentSessionResumeFailureRecord[] = [] + for (const failure of await read()) { + const sessionId = failure.marker.sessionId + if (!deps.sessions.has(sessionId)) { + await deps.reveal(sessionId) + } + const snapshot = deps.sessions.get(sessionId)?.journal.snapshot() + // An unreadable journal decides nothing; the record stays until something that can decide. + if (snapshot && failureAnsweredByChat(failure, snapshot)) { + superseded.push(failure) + } else { + current.push(failure) + } + } + const capsule = deps.capsule + if (capsule && superseded.length > 0) { + const gone = superseded.map((failure) => ({ + sessionId: failure.marker.sessionId, + failedAt: failure.failedAt + })) + void deps + .enqueue(() => capsule.forgetFailures(gone, deps.now())) + .catch(() => { + console.warn('[structured-agent-session] pruning superseded restart failures failed') + }) + } + return current.flatMap(toRow) + } + + const settle: StructuredAgentSessionRestartFailureLedger['settle'] = async ( + operationId, + outcomes, + action + ) => { + const capsule = deps.capsule + if (!capsule) { + return + } + const completed: string[] = [] + const failures: AgentSessionResumeFailureInput[] = [] + const promptBySession = new Map( + action.candidates.map((candidate) => [candidate.sessionId, candidate.latestPrompt]) + ) + for (const outcome of outcomes) { + const resumed = outcome.outcome === 'resumed' + // Ineligible means the chat moved on by itself (finished, or is waiting on the user), so there + // is nothing for the user to do and the offer is simply spent. + const failure = resumed + ? action.failureAfterResume(outcome.sessionId) + : outcome.reason === STRUCTURED_AGENT_SESSION_RESUME_NOT_ELIGIBLE + ? null + : 'refused' + if (failure === null) { + completed.push(outcome.sessionId) + continue + } + failures.push({ + sessionId: outcome.sessionId, + failedAt: deps.now(), + outcome: failure, + reason: resumed + ? action.failureReason(outcome.sessionId) + : (outcome.reason ?? 'agent_session_resume_refused'), + latestPrompt: promptBySession.get(outcome.sessionId) ?? '', + latestUserItemId: action.attempts.latestUserItemId(outcome.sessionId) + }) + } + await deps + .enqueue(() => capsule.completeResume(operationId, completed, deps.now())) + .catch(() => { + console.warn('[structured-agent-session] restart offer completion failed') + }) + // Filed before the rollback so a failure the user must act on is never reopened as an offer + // that would silently re-run it. + await deps + .enqueue(() => capsule.failResume(operationId, failures, deps.now())) + .catch(() => { + console.warn('[structured-agent-session] restart failure record failed') + }) + // This only reopens rows still owned by this operation. Rows removed by completeResume stay + // removed, even when the write of a later bookkeeping step fails. + await deps + .enqueue(() => capsule.rollbackResume(operationId, deps.now())) + .catch(() => { + console.warn('[structured-agent-session] restart offer rollback failed') + }) + } + + const attempts: StructuredAgentSessionRestartFailureLedger['attempts'] = (markers) => { + const observed = new Map() + return { + // Observed after the attempt, so the continuation's own message is part of the filed state. + observe: (sessionId) => { + const latest = liveStructuredAgentSessionLatestUserItemId(deps.sessions, sessionId) + if (latest !== undefined) { + observed.set(sessionId, latest) + } + }, + latestUserItemId: (sessionId) => { + const latest = observed.get(sessionId) + return latest !== undefined ? latest : (markers.get(sessionId)?.latestUserItemId ?? null) + } + } + } + + return { + read, + list, + attempts, + settle, + dismiss: (sessionIds, beforeClearAll) => + deps.enqueue(async () => { + if (sessionIds !== undefined) { + return (await deps.capsule?.dismiss(sessionIds, deps.now())) ?? 0 + } + beforeClearAll() + return (await deps.capsule?.clearAll(deps.now())) ?? 0 + }) + } +} 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 new file mode 100644 index 00000000000..6773b8eb9af --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -0,0 +1,149 @@ +// A chat interrupted mid-turn by a restart, rebuilt on a fresh host over the same store, for the +// restart-resume ownership and failure tests. + +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { expect, vi } from 'vitest' +import { + AgentSessionRecoveryCapsule, + AGENT_SESSION_RECOVERY_CAPSULE_FILE +} from '../../runtime/agent-session-recovery-capsule' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { parseAgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' +import { + adapter, + attach, + CALLER, + envelope, + hostTestState, + replaceHostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD, + hostTestMessage +} from './structured-agent-session-host-test-data' + +export const GRACE = 15_000 + +export async function interruptedRestart( + work: 'turn' | 'submission' = 'turn', + historyBoundaryConsistent = true +) { + const previous = hostTestState() + await attach() + const events = previous.acquire.mock.calls[0]?.[0].events + if (!events) { + throw new Error('missing provider event sink') + } + if (work === 'submission') { + previous.dispatch.mockResolvedValueOnce({ state: 'admitted' }) + const body = hostTestMessage('Perform the original task') + await previous.host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + } else { + events.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'interrupted-turn', ordinal: 1 }, + { kind: 'turn', turnId: 'interrupted-turn', state: 'running' } + ) + } + await previous.host.flushStreamedEvents(SESSION) + await previous.host.flushAllStreamedEvents() + const store = await AgentSessionRecordStore.open({ + directory: join(previous.root, 'store'), + hostId: 'local' + }) + const closeSession = vi.fn(async () => true) + const host = new StructuredAgentSessionHost({ + store, + adapter: { + ...adapter(), + closeSession, + ...(work === 'submission' + ? { + providerHistoryWindow: async () => ({ + items: [], + boundaryConsistent: historyBoundaryConsistent, + turnInFlight: false + }) + } + : {}) + }, + journalRoot: previous.root, + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-next', + probeOwner: async () => ({ outcome: 'pid-absent' }), + recoveryCapsule: new AgentSessionRecoveryCapsule(previous.root), + releaseGraceMs: GRACE, + now: () => NOW + }) + replaceHostTestState({ store, host }) + previous.acquire.mockClear() + previous.releaseAcquisition.mockClear() + previous.dispatch.mockClear() + const capsule = JSON.parse( + await readFile(join(previous.root, AGENT_SESSION_RECOVERY_CAPSULE_FILE), 'utf8') + ) + const marker = parseAgentSessionResumeMarker(capsule.entries[0]?.marker) + return { ...hostTestState(), host, store, closeSession, marker } +} + +export function statusNotes(host: StructuredAgentSessionHost) { + return host + .journalSnapshot(SESSION) + .items.flatMap((item) => + item.body.kind === 'status' ? [{ text: item.body.text, tone: item.body.tone }] : [] + ) +} + +/** A reattach that succeeds and a continuation the host refuses: the provider finished the turn + * while the continuation was being recorded, as the superseded-evidence cases above set up. */ +/** `userAnswers` has the user reply in the chat just before or after its own attempt, while the + * rest of a batch would still be running. */ +export async function supersededRefusal(userAnswers?: 'before' | 'after') { + const { host, acquire, dispatch, root } = await interruptedRestart() + await host.restartResume.list() + await host.hold(SESSION, 'pane') + const events = acquire.mock.calls[0]?.[0].events + if (!events) { + throw new Error('missing resumed provider event sink') + } + const append = AgentSessionJournal.prototype.appendSubmission + const writing = vi.spyOn(AgentSessionJournal.prototype, 'appendSubmission') + writing.mockImplementationOnce(async function (this: AgentSessionJournal, input) { + const cursor = await append.call(this, input) + events.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'interrupted-turn', ordinal: 1 }, + { kind: 'turn', turnId: 'interrupted-turn', state: 'completed' }, + { lifecycle: true } + ) + return cursor + }) + const admit = StructuredAgentSessionResumeAdmission.prototype.run + const admitting = vi.spyOn(StructuredAgentSessionResumeAdmission.prototype, 'run') + const body = hostTestMessage('Carry on from where you stopped') + const answer = () => + host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + if (userAnswers) { + admitting.mockImplementationOnce(async function (this, ...args) { + await (userAnswers === 'before' ? answer() : null) + try { + return await admit.apply(this, args) + } finally { + await (userAnswers === 'after' ? answer() : null) + } + }) + } + try { + const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') + expect(result.continued).toMatchObject([{ outcome: 'refused' }]) + expect(dispatch).toHaveBeenCalledTimes(userAnswers ? 1 : 0) + return { host, root, result } + } finally { + writing.mockRestore() + admitting.mockRestore() + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index 6cfe9dad1d7..bf770972ecb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, rm, writeFile } from 'node:fs/promises' import { AgentSessionRecoveryCapsule, AGENT_SESSION_RECOVERY_CAPSULE_FILE @@ -6,20 +6,28 @@ import { import { parseAgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { StructuredAgentSessionHost } from './structured-agent-session-host' import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { pendingApproval } from './structured-agent-session-restart-resume-test-harness' import { restartContinuationEnvelope } from './structured-agent-session-restart-continuation' -import { AGENT_SESSION_RESTART_CONTINUATION_NOTE } from '../../../shared/agent-session-restart-continuation' +import { + AGENT_SESSION_RESTART_CONTINUATION_NOTE, + AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, + AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE +} from '../../../shared/agent-session-restart-continuation' +import { latestStructuredAgentSessionUserItem } from '../../../shared/structured-agent-session-projection' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { STRUCTURED_AGENT_SESSION_RESTART_CONTINUATION_CALLER } from './structured-agent-session-restart-resume-wiring' import { - adapter, + GRACE, + interruptedRestart, + statusNotes, + supersededRefusal +} from './structured-agent-session-restart-interruption-test-harness' +import { attach, CALLER, envelope, - hostTestState, - replaceHostTestState + hostTestState } from './structured-agent-session-host-test-harness' import { HOST_TEST_NOW as NOW, @@ -28,69 +36,6 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' -const GRACE = 15_000 - -async function interruptedRestart( - work: 'turn' | 'submission' = 'turn', - historyBoundaryConsistent = true -) { - const previous = hostTestState() - await attach() - const events = previous.acquire.mock.calls[0]?.[0].events - if (!events) { - throw new Error('missing provider event sink') - } - if (work === 'submission') { - previous.dispatch.mockResolvedValueOnce({ state: 'admitted' }) - const body = hostTestMessage('Perform the original task') - await previous.host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) - } else { - events.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'interrupted-turn', ordinal: 1 }, - { kind: 'turn', turnId: 'interrupted-turn', state: 'running' } - ) - } - await previous.host.flushStreamedEvents(SESSION) - await previous.host.flushAllStreamedEvents() - const store = await AgentSessionRecordStore.open({ - directory: join(previous.root, 'store'), - hostId: 'local' - }) - const closeSession = vi.fn(async () => true) - const host = new StructuredAgentSessionHost({ - store, - adapter: { - ...adapter(), - closeSession, - ...(work === 'submission' - ? { - providerHistoryWindow: async () => ({ - items: [], - boundaryConsistent: historyBoundaryConsistent, - turnInFlight: false - }) - } - : {}) - }, - journalRoot: previous.root, - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-next', - probeOwner: async () => ({ outcome: 'pid-absent' }), - recoveryCapsule: new AgentSessionRecoveryCapsule(previous.root), - releaseGraceMs: GRACE, - now: () => NOW - }) - replaceHostTestState({ store, host }) - previous.acquire.mockClear() - previous.releaseAcquisition.mockClear() - previous.dispatch.mockClear() - const capsule = JSON.parse( - await readFile(join(previous.root, AGENT_SESSION_RECOVERY_CAPSULE_FILE), 'utf8') - ) - const marker = parseAgentSessionResumeMarker(capsule.entries[0]?.marker) - return { ...hostTestState(), host, store, closeSession, marker } -} - afterEach(() => vi.useRealTimers()) it('publishes continuation attribution to the subscribed chat without another provider event', async () => { @@ -322,10 +267,15 @@ it.each([ expect(host.journalSnapshot(SESSION).submissions[1]?.dispatchState).toBe('rejected') host.release(SESSION, 'pane') expect(host.isHeld(SESSION)).toBe(false) - expect(await host.restartResume.continueAfterRestart([SESSION], 'retry')).toEqual({ + // The offer is spent, but the refusal is kept as a durable failure: a retry finds it, and the + // superseded chat is still not eligible, so nothing runs and the record stays for the user. + expect(await host.restartResume.continueAfterRestart([SESSION], 'retry')).toMatchObject({ resumed: [], continued: [], - sessions: [] + sessions: [], + failed: [ + { sessionId: SESSION, outcome: 'refused', reason: 'agent_session_restart_work_superseded' } + ] }) expect(dispatch).not.toHaveBeenCalled() if (settlementFails) { @@ -445,10 +395,18 @@ it.each([false, true])( expect(dispatch).toHaveBeenCalledTimes(1) expect(host.journalSnapshot(SESSION).submissions[0]?.dispatchState).toBe('accepted') expect(result.continued).toMatchObject([{ sessionId: SESSION, outcome: 'unknown' }]) + // Filed as unconfirmed, with a warning in the chat; the continuation's own message is part of + // the filed state, so it does not retire the record it caused. + expect(result.failed).toMatchObject([{ sessionId: SESSION, outcome: 'unconfirmed' }]) + expect(statusNotes(host)).toContainEqual({ + text: AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE, + tone: 'warning' + }) host.release(SESSION, 'pane') expect(host.isHeld(SESSION)).toBe(false) await host.restartResume.continueAfterRestart([SESSION], 'retry') expect(dispatch).toHaveBeenCalledTimes(1) + expect(await host.restartResume.listFailures()).toMatchObject([{ outcome: 'unconfirmed' }]) expect(warning.mock.calls.flat()).not.toContainEqual( expect.objectContaining({ message: 'operation outcome could not be persisted' }) ) @@ -630,7 +588,8 @@ it('fails closed on corrupt recovery storage while ordinary hold and send still expect(await host.restartResume.continueAfterRestart([SESSION], 'modal')).toEqual({ resumed: [], continued: [], - sessions: [] + sessions: [], + failed: [] }) await host.hold(SESSION, 'pane') const body = hostTestMessage('A fresh ordinary request') @@ -638,11 +597,133 @@ it('fails closed on corrupt recovery storage while ordinary hold and send still await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) ).toMatchObject({ ok: true }) expect(dispatch).toHaveBeenCalledTimes(1) - expect(warning).toHaveBeenCalledTimes(3) + // list; the action's read of offers and of failures; the post-action refresh of both. + expect(warning).toHaveBeenCalledTimes(5) warning.mockRestore() host.release(SESSION, 'pane') }) +// The toast is gone in seconds and the offer is spent by the reattach, so without this record +// nothing on any surface would still name the chat the user has to continue by hand. +it('keeps a refused continuation as a durable failure that names the chat and the reason', async () => { + const { host, root, result } = await supersededRefusal() + const failure = { + sessionId: SESSION, + outcome: 'refused', + reason: 'agent_session_restart_work_superseded', + latestPrompt: expect.any(String), + agent: 'codex', + retryable: false + } + expect(result).toMatchObject({ sessions: [], failed: [failure] }) + // The chat itself says what happened and what to do. + expect(statusNotes(host)).toContainEqual({ + text: AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE, + tone: 'error' + }) + // The refused continuation's own message is the newest user item, and it does not retire the + // failure it caused. + expect(latestStructuredAgentSessionUserItem(host.journalSnapshot(SESSION).items)?.itemId).toBe( + agentJournalSubmissionKey(host.journalSnapshot(SESSION).submissions.at(-1)!.clientMessageId) + ) + expect(await host.restartResume.list()).toEqual([]) + expect(await host.restartResume.listFailures()).toMatchObject([failure]) + // Durable: a fresh reader of the same file sees it too. + expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toMatchObject([ + { marker: { sessionId: SESSION }, outcome: 'refused' } + ]) + host.release(SESSION, 'pane') +}) + +// The failure asked the user to continue the chat themselves; their own message is that +// continuation. Nothing on the send path clears it: the listing sees the newer message. +it('retires a recorded failure once the user sends in that chat, with no send hook', async () => { + const { host, root } = await supersededRefusal() + const body = hostTestMessage('Carry on from where you stopped') + await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + expect(await host.restartResume.listFailures()).toEqual([]) + // Pruned from the file too, not only hidden. + await vi.waitFor(async () => { + expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toEqual([]) + }) + host.release(SESSION, 'pane') +}) + +// The user can reply in a chat while other chats in the same action are still being continued, +// before its turn in the batch or after its own note asks them to. Either reply answers the failure. +it.each(['before', 'after'] as const)( + 'retires a failure the user answered %s its own attempt, before the action settled', + async (userAnswers) => { + const { host, root, result } = await supersededRefusal(userAnswers) + expect(result.resumed).toMatchObject([ + userAnswers === 'before' ? { reason: 'agent_session_resume_not_eligible' } : {} + ]) + expect( + statusNotes(host).some( + (note) => note.text === AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE + ) + ).toBe(userAnswers === 'after') + expect(result.failed).toEqual([]) + await vi.waitFor(async () => { + expect(await new AgentSessionRecoveryCapsule(root).listFailed(NOW)).toEqual([]) + }) + host.release(SESSION, 'pane') + } +) + +it('removes a failure when a named retry succeeds', async () => { + const { host, root, dispatch } = await interruptedRestart() + const capsule = new AgentSessionRecoveryCapsule(root) + expect(await host.restartResume.list()).toHaveLength(1) + const [pending] = await capsule.list(NOW) + await capsule.beginResume([SESSION], 'earlier-action', NOW) + await capsule.failResume( + 'earlier-action', + [ + { + sessionId: SESSION, + failedAt: NOW, + outcome: 'refused', + reason: 'agent_session_conflict', + latestPrompt: '', + latestUserItemId: pending!.latestUserItemId + } + ], + NOW + ) + expect(await host.restartResume.listFailures()).toMatchObject([{ retryable: true }]) + // An unselective action leaves it alone; naming it retries it. + expect((await host.restartResume.continueAfterRestart(undefined, 'all')).resumed).toEqual([]) + const retried = await host.restartResume.continueAfterRestart([SESSION], 'retry') + expect(retried.continued).toMatchObject([{ outcome: 'continued' }]) + expect(dispatch).toHaveBeenCalledTimes(1) + expect(retried.failed).toEqual([]) + expect(await capsule.listFailed(NOW)).toEqual([]) +}) + +it('dismisses one failure by name and leaves the rest of the durable records alone', async () => { + const { host, root } = await supersededRefusal() + const capsule = new AgentSessionRecoveryCapsule(root) + const other = parseAgentSessionResumeMarker({ + sessionId: 'session-other', + work: { kind: 'turn', id: 'turn-other' }, + latestUserItemId: null, + recordedAt: NOW, + trigger: 'quit', + providerHandleRoot: 'codex:"thread-other"', + teardownId: 'teardown-other' + }) + if (!other) { + throw new Error('fixture marker did not parse') + } + await capsule.record([other], NOW) + + expect(await host.restartResume.dismiss([SESSION])).toBe(1) + expect(await host.restartResume.listFailures()).toEqual([]) + expect(await capsule.list(NOW)).toEqual([other]) + host.release(SESSION, 'pane') +}) + it('logs teardown capsule publication failure and still releases the provider', async () => { const previous = hostTestState() await attach() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts index 2524b10d250..8852809d332 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-host.ts @@ -1,5 +1,5 @@ // Restart offers are durable per-session records. Listing is read-only; only an explicit action -// reserves records, and only a completed action removes them. +// reserves records, and only a completed action removes them — or files what went wrong. import { randomUUID } from 'node:crypto' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' @@ -17,8 +17,15 @@ import type { import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { createStructuredAgentSessionRestartCandidateReader } from './structured-agent-session-restart-candidates' +import { + continuationFailureOutcome, + createStructuredAgentSessionRestartFailureLedger +} from './structured-agent-session-restart-failure-ledger' import { createStructuredAgentSessionRestartOperationQueue } from './structured-agent-session-restart-operation-queue' -import type { StructuredAgentSessionResumeCandidate } from './structured-agent-session-restart-resume-set' +import type { + StructuredAgentSessionResumeCandidate, + StructuredAgentSessionResumeFailure +} from './structured-agent-session-restart-resume-set' import { resumeStructuredAgentSessionsFromRestart, StructuredAgentSessionResumeAdmission, @@ -26,10 +33,11 @@ import { } from './structured-agent-session-restart-resume-runner' import { continueStructuredAgentSessionAfterRestart, - RestartContinuationSupersededError, + noteRestartReattachFailed, + restartContinuationDeps, type StructuredAgentSessionContinuationOutcome } from './structured-agent-session-restart-continuation' -import { structuredAgentSessionsWorkingAtTeardown } from './structured-agent-session-working-at-teardown' +import { createStructuredAgentSessionRestartWitnesses } from './structured-agent-session-restart-witnesses' type LiveSession = { journal: AgentSessionJournal; hasProviderChild: boolean; fence: number } @@ -56,6 +64,8 @@ export type StructuredAgentSessionRestartResume = { confirmStoppedMarker: (sessionId: string) => void recordMarkers: () => Promise list: () => Promise + /** Offers already acted on whose agent did not carry on. Read-only; nothing here is spent. */ + listFailures: () => Promise resume: ( sessionIds: readonly string[] | undefined, owner: string @@ -67,26 +77,22 @@ export type StructuredAgentSessionRestartResume = { resumed: StructuredAgentSessionResumeOutcome[] continued: StructuredAgentSessionContinuationOutcome[] sessions?: StructuredAgentSessionResumeCandidate[] + failed?: StructuredAgentSessionResumeFailure[] }> - dismiss: () => Promise + /** Named sessions forget their offer or failure; unnamed, every durable record goes. */ + dismiss: (sessionIds?: readonly string[]) => Promise } export function createStructuredAgentSessionRestartResume( deps: { store: AgentSessionRecordStore adapter: StructuredAgentSessionAdapter - recoveryCapsule?: Pick< - AgentSessionRecoveryCapsule, - 'list' | 'record' | 'beginResume' | 'completeResume' | 'rollbackResume' | 'clearAll' - > + recoveryCapsule?: AgentSessionRecoveryCapsule }, sessions: ReadonlyMap, surfaces: StructuredAgentSessionRestartResumeSurfaces ): StructuredAgentSessionRestartResume { const admission = new StructuredAgentSessionResumeAdmission() - const teardownId = randomUUID() - let teardownMarkers = new Map() - const confirmedMarkers = new Map() const enqueueRecoveryOperation = createStructuredAgentSessionRestartOperationQueue() const derive = createStructuredAgentSessionRestartCandidateReader({ @@ -95,6 +101,27 @@ export function createStructuredAgentSessionRestartResume( adapter: deps.adapter, now: surfaces.now }) + const witnesses = createStructuredAgentSessionRestartWitnesses({ + sessions, + getRecord: deps.store.getRecord, + derive, + ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), + teardownId: randomUUID(), + now: surfaces.now, + enqueue: enqueueRecoveryOperation + }) + const failures = createStructuredAgentSessionRestartFailureLedger({ + ...(deps.recoveryCapsule ? { capsule: deps.recoveryCapsule } : {}), + sessions, + reveal: async (sessionId) => { + await surfaces.revealSession(sessionId).catch(() => null) + }, + getRecord: deps.store.getRecord, + adapter: deps.adapter, + retryable: (marker) => derive([marker], 'may-be-held').length === 1, + now: surfaces.now, + enqueue: enqueueRecoveryOperation + }) const readMarkers = async (): Promise => { try { @@ -107,6 +134,22 @@ export function createStructuredAgentSessionRestartResume( } } + /** The markers an explicit action may act on: every pending offer, plus a recorded failure when + * the action names it — a retry. An unselective action never re-runs a failure. */ + const readActionMarkers = async ( + sessionIds: readonly string[] | undefined + ): Promise => { + const pending = await readMarkers() + if (sessionIds === undefined) { + return pending + } + const named = new Set(sessionIds) + const retried = (await failures.read()) + .map((failure) => failure.marker) + .filter((marker) => named.has(marker.sessionId)) + return [...pending, ...retried] + } + const revealMarkers = async (markers: readonly AgentSessionResumeMarker[]): Promise => { for (const marker of markers) { if (!sessions.has(marker.sessionId)) { @@ -123,16 +166,26 @@ export function createStructuredAgentSessionRestartResume( return derive(markers, 'may-be-held') } + const continuationHost = { + ...surfaces, + sessions, + stillResumable: (marker: AgentSessionResumeMarker, pendingContinuationId: string) => + derive([marker], 'may-be-held', { pendingContinuationId }).length === 1 + } + const run = async ( sessionIds: readonly string[] | undefined, owner: string, - afterAcquire?: (marker: AgentSessionResumeMarker) => Promise + afterAcquire?: (marker: AgentSessionResumeMarker) => Promise, + settlement: Omit[2], 'candidates' | 'attempts'> = { + failureAfterResume: () => null, + failureReason: () => 'agent_session_resume_refused' + } ): Promise => { // An explicit action supersedes teardown witnesses captured by this host. The durable mutation // lane below also drains a publication already in flight before completion. - confirmedMarkers.clear() - teardownMarkers.clear() - const markers = await readMarkers() + witnesses.clear() + const markers = await readActionMarkers(sessionIds) await revealMarkers(markers) const requested = new Set(sessionIds ?? markers.map((marker) => marker.sessionId)) const eligible = derive(markers, 'may-be-held').filter((candidate) => @@ -153,6 +206,7 @@ export function createStructuredAgentSessionRestartResume( )) ?? [] const markersBySession = new Map(reserved.map((marker) => [marker.sessionId, marker])) const candidates = derive(reserved, 'may-be-held') + const attempts = failures.attempts(markersBySession) let outcomes: StructuredAgentSessionResumeOutcome[] try { @@ -166,12 +220,17 @@ export function createStructuredAgentSessionRestartResume( resume: async (sessionId) => { const holder = `restart-resume:${sessionId}` try { - await surfaces.hold(sessionId, holder) + await surfaces.hold(sessionId, holder).catch(async (error: unknown) => { + // The reattach failure is filed like any other, so the chat must say so too. + await noteRestartReattachFailed(continuationHost, sessionId) + throw error + }) const marker = markersBySession.get(sessionId) if (marker) { await afterAcquire?.(marker) } } finally { + attempts.observe(sessionId) surfaces.release(sessionId, holder) } } @@ -189,24 +248,7 @@ export function createStructuredAgentSessionRestartResume( } throw error } - - const completed = outcomes - .filter((outcome) => outcome.outcome === 'resumed') - .map((outcome) => outcome.sessionId) - if (deps.recoveryCapsule) { - await enqueueRecoveryOperation(() => - deps.recoveryCapsule!.completeResume(operationId, completed, surfaces.now()) - ).catch(() => { - console.warn('[structured-agent-session] restart offer completion failed') - }) - // This only reopens rows still owned by this operation. Rows removed by completeResume stay - // removed, even when the write of a later bookkeeping step fails. - await enqueueRecoveryOperation(() => - deps.recoveryCapsule!.rollbackResume(operationId, surfaces.now()) - ).catch(() => { - console.warn('[structured-agent-session] restart offer rollback failed') - }) - } + await failures.settle(operationId, outcomes, { candidates, attempts, ...settlement }) return outcomes } @@ -217,47 +259,35 @@ export function createStructuredAgentSessionRestartResume( resumed: StructuredAgentSessionResumeOutcome[] continued: StructuredAgentSessionContinuationOutcome[] sessions?: StructuredAgentSessionResumeCandidate[] + failed?: StructuredAgentSessionResumeFailure[] }> => { const continued: StructuredAgentSessionContinuationOutcome[] = [] - const resumed = await run(sessionIds, owner, async (marker) => { - continued.push( - await continueStructuredAgentSessionAfterRestart( - { - currentFence: (sessionId) => sessions.get(sessionId)?.fence ?? null, - send: (input) => - surfaces.send({ - ...input, - beforeRun: () => { - const options = { pendingContinuationId: input.envelope.clientOperationId } - if (derive([marker], 'may-be-held', options).length !== 1) { - throw new RestartContinuationSupersededError() - } - } - }), - awaitSettlement: async (sessionId, clientMessageId) => - (await surfaces.awaitSendSettlement(sessionId, clientMessageId))?.value.submission, - onNoteFailed: surfaces.onNoteFailed, - note: async (sessionId, text) => { - const session = sessions.get(sessionId) - if (!session) { - return - } - await session.journal.appendItem( - { - provider: 'orca', - clientMessageId: `restart-continuation:${sessionId}:${surfaces.now()}` - }, - { kind: 'status', text }, - { fence: session.fence } - ) - surfaces.publish(sessionId, session.journal) - } - }, - marker.sessionId, - marker + const continuationFor = (sessionId: string) => + continued.find((outcome) => outcome.sessionId === sessionId) + const resumed = await run( + sessionIds, + owner, + async (marker) => { + continued.push( + await continueStructuredAgentSessionAfterRestart( + restartContinuationDeps(continuationHost, marker), + marker.sessionId, + marker + ) ) - ) - }) + }, + { + failureAfterResume: (sessionId) => { + const outcome = continuationFor(sessionId) + // Reattached but never asked to continue: nothing confirms the agent carried on. + return outcome ? continuationFailureOutcome(outcome.outcome) : 'unconfirmed' + }, + failureReason: (sessionId) => { + const outcome = continuationFor(sessionId) + return outcome?.reason ?? outcome?.outcome ?? 'agent_session_continuation_unknown' + } + } + ) for (const outcome of resumed) { if (outcome.outcome !== 'resumed') { continued.push({ @@ -268,58 +298,30 @@ export function createStructuredAgentSessionRestartResume( } } let remainingCandidates: StructuredAgentSessionResumeCandidate[] | undefined + let remainingFailures: StructuredAgentSessionResumeFailure[] | undefined try { remainingCandidates = await list() + remainingFailures = await failures.list() } catch { console.warn('[structured-agent-session] restart offer refresh failed after action') } return { resumed, continued, - ...(remainingCandidates === undefined ? {} : { sessions: remainingCandidates }) + ...(remainingCandidates === undefined ? {} : { sessions: remainingCandidates }), + ...(remainingFailures === undefined ? {} : { failed: remainingFailures }) } } return { - captureMarkers: (trigger) => { - confirmedMarkers.clear() - teardownMarkers.clear() - teardownMarkers = new Map( - structuredAgentSessionsWorkingAtTeardown({ - sessions, - getRecord: deps.store.getRecord, - trigger, - teardownId, - now: surfaces.now() - }).map((marker) => [marker.sessionId, marker]) - ) - }, - confirmStoppedMarker: (sessionId) => { - const marker = teardownMarkers.get(sessionId) - teardownMarkers.delete(sessionId) - try { - if (marker && derive([marker], 'may-be-held', { providerStopped: true }).length === 1) { - confirmedMarkers.set(sessionId, marker) - } - } catch { - console.warn('[structured-agent-session] recovery witness validation failed') - } - }, - recordMarkers: async () => { - await enqueueRecoveryOperation(async () => { - await deps.recoveryCapsule?.record([...confirmedMarkers.values()], surfaces.now()) - }) - }, + captureMarkers: witnesses.capture, + confirmStoppedMarker: witnesses.confirmStopped, + recordMarkers: witnesses.record, list, - dismiss: async () => { - return enqueueRecoveryOperation(async () => { - // Do not let a teardown witness already captured in this host republish after explicit - // dismissal. A later capture is a new interruption and may create a fresh offer normally. - confirmedMarkers.clear() - teardownMarkers.clear() - return (await deps.recoveryCapsule?.clearAll(surfaces.now())) ?? 0 - }) - }, + listFailures: failures.list, + // Do not let a teardown witness already captured in this host republish after explicit + // dismissal. A later capture is a new interruption and may create a fresh offer normally. + dismiss: (sessionIds) => failures.dismiss(sessionIds, witnesses.clear), resume: (sessionIds, owner) => run(sessionIds, owner), continueAfterRestart } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts index 390f3df1d0a..33a07e80335 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-runner.ts @@ -19,6 +19,9 @@ export const STRUCTURED_AGENT_SESSION_RESUME_CONCURRENCY = 3 export const STRUCTURED_AGENT_SESSION_RESUME_IN_PROGRESS = 'agent_session_resume_already_in_progress' +/** The chat stopped being resumable between listing and acting; nothing was attempted. */ +export const STRUCTURED_AGENT_SESSION_RESUME_NOT_ELIGIBLE = 'agent_session_resume_not_eligible' + export type StructuredAgentSessionResumeOutcome = { sessionId: string outcome: 'resumed' | 'refused' @@ -111,7 +114,7 @@ async function resumeOne( return { sessionId, outcome: 'refused' as const, - reason: 'agent_session_resume_not_eligible' + reason: STRUCTURED_AGENT_SESSION_RESUME_NOT_ELIGIBLE } } await deps.resume(sessionId) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts index 3a6af73ab5d..81675443ef7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-set.ts @@ -20,6 +20,7 @@ import { } from '../../../shared/agent-session-provider-handle' import { isExpiredAgentSessionResumeMarker, + type AgentSessionResumeFailureOutcome, type AgentSessionResumeMarker, type AgentSessionResumeTrigger, type AgentSessionResumeWork @@ -46,6 +47,18 @@ export type StructuredAgentSessionResumeCandidate = { model?: string } +/** An offer that was acted on and did not end with the agent carrying on. Same row shape as the + * candidate so one surface renders both, plus what went wrong and when. */ +export type StructuredAgentSessionResumeFailure = StructuredAgentSessionResumeCandidate & { + failedAt: number + outcome: AgentSessionResumeFailureOutcome + /** The host's or provider's refusal code, verbatim, so it can be quoted in a report. */ + reason: string + /** Whether naming it in an action would run it again. A continuation the chat already holds, or + * work that has since finished, makes a retry a no-op no matter what the reason says. */ + retryable: boolean +} + export type StructuredAgentSessionResumeSetInput = { markers: readonly AgentSessionResumeMarker[] getRecord: (sessionId: string) => AgentSessionRecord | null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts new file mode 100644 index 00000000000..37b00e8ea90 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-witnesses.ts @@ -0,0 +1,73 @@ +// Teardown's word on which sessions were genuinely working when the app went away. +// +// Captured from the live runtime at teardown, confirmed per session once its child has stopped, +// and only then written. A marker is never derived from a persisted `running` row, which survives +// a crash and would resurrect work nobody is doing. + +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { + AgentSessionResumeMarker, + AgentSessionResumeTrigger +} from '../../../shared/agent-session-resume-marker' +import type { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' +import type { StructuredAgentSessionRestartCandidateReader } from './structured-agent-session-restart-candidates' +import { structuredAgentSessionsWorkingAtTeardown } from './structured-agent-session-working-at-teardown' + +export type StructuredAgentSessionRestartWitnesses = { + capture: (trigger: AgentSessionResumeTrigger) => void + confirmStopped: (sessionId: string) => void + record: () => Promise + /** An explicit action on the offer supersedes witnesses this host has not yet written. */ + clear: () => void +} + +export function createStructuredAgentSessionRestartWitnesses(deps: { + sessions: Parameters[0]['sessions'] + getRecord: (sessionId: string) => AgentSessionRecord | null + derive: StructuredAgentSessionRestartCandidateReader + capsule?: Pick + teardownId: string + now: () => number + enqueue: (operation: () => Promise) => Promise +}): StructuredAgentSessionRestartWitnesses { + let captured = new Map() + const confirmed = new Map() + const clear = (): void => { + confirmed.clear() + captured.clear() + } + return { + capture: (trigger) => { + clear() + captured = new Map( + structuredAgentSessionsWorkingAtTeardown({ + sessions: deps.sessions, + getRecord: deps.getRecord, + trigger, + teardownId: deps.teardownId, + now: deps.now() + }).map((marker) => [marker.sessionId, marker]) + ) + }, + confirmStopped: (sessionId) => { + const marker = captured.get(sessionId) + captured.delete(sessionId) + try { + if ( + marker && + deps.derive([marker], 'may-be-held', { providerStopped: true }).length === 1 + ) { + confirmed.set(sessionId, marker) + } + } catch { + console.warn('[structured-agent-session] recovery witness validation failed') + } + }, + record: async () => { + await deps.enqueue(async () => { + await deps.capsule?.record([...confirmed.values()], deps.now()) + }) + }, + clear + } +} diff --git a/src/main/runtime/agent-session-recovery-capsule-entries.ts b/src/main/runtime/agent-session-recovery-capsule-entries.ts new file mode 100644 index 00000000000..8f4bfa3e166 --- /dev/null +++ b/src/main/runtime/agent-session-recovery-capsule-entries.ts @@ -0,0 +1,205 @@ +// The shape of the recovery capsule on disk, and how a stored set of records is read back. +// +// Split from the capsule so the file format — offer entries, failure records, the legacy v1 layout +// — can be read on its own. Every value parsed here re-enters from a file this process did not +// necessarily write, including one written by an older or newer build. + +import { z } from 'zod' +import { + AGENT_SESSION_RESUME_FAILURE_OUTCOMES, + isExpiredAgentSessionResumeMarker, + parseAgentSessionResumeMarker, + type AgentSessionResumeFailureOutcome, + type AgentSessionResumeMarker +} from '../../shared/agent-session-resume-marker' + +const RESUME_ACTION_LEASE_TTL_MS = 10 * 60 * 1000 +export const MAX_FAILURE_FIELD_LENGTH = 512 + +const legacyCapsuleSchema = z.object({ version: z.literal(1), markers: z.array(z.unknown()) }) +const entrySchema = z.object({ + state: z.enum(['pending', 'in-progress']), + operationId: z.string().min(1).optional(), + startedAt: z.number().int().nonnegative().optional(), + marker: z.unknown(), + replacement: z.unknown().optional() +}) +const failureSchema = z.object({ + marker: z.unknown(), + failedAt: z.number().int().nonnegative(), + outcome: z.enum(AGENT_SESSION_RESUME_FAILURE_OUTCOMES), + reason: z.string().max(MAX_FAILURE_FIELD_LENGTH), + latestPrompt: z.string().max(MAX_FAILURE_FIELD_LENGTH), + latestUserItemId: z.string().max(MAX_FAILURE_FIELD_LENGTH).nullable() +}) +// Failures sit under their own optional key rather than as a third entry state: an older build's +// entry parser rejects an unknown state and would lose every offer, but it ignores an unknown key. +const capsuleSchema = z.object({ + version: z.literal(2), + entries: z.array(z.unknown()), + dismissedAt: z.number().int().nonnegative().optional(), + failed: z.unknown().optional() +}) + +/** What an acted-on offer left behind when the agent did not carry on. Current only while nothing + * newer happened in that chat; also dies with the marker's TTL, a dismissal, a successful retry, + * or a newer teardown of the same chat. */ +export type AgentSessionResumeFailureRecord = { + marker: AgentSessionResumeMarker + failedAt: number + outcome: AgentSessionResumeFailureOutcome + reason: string + /** The prompt the offer quoted, snapshotted because the session may no longer be readable. */ + latestPrompt: string + /** The chat's newest user message when this was filed, as the marker records it at teardown. A + * different newest message means the user has since acted in that chat. */ + latestUserItemId: string | null +} + +export type AgentSessionResumeFailureInput = Omit & { + sessionId: string +} + +export type RecoveryEntry = { + state: 'pending' | 'in-progress' + operationId?: string + startedAt?: number + marker: AgentSessionResumeMarker + replacement?: AgentSessionResumeMarker +} + +export type RecoveryCapsuleState = { + entries: RecoveryEntry[] + failed: AgentSessionResumeFailureRecord[] + dismissedAt?: number +} + +function parseMarker(value: unknown): AgentSessionResumeMarker { + const marker = parseAgentSessionResumeMarker(value) + if (!marker) { + throw new Error('agent_session_recovery_capsule_invalid') + } + return marker +} + +function parseEntry(value: unknown): RecoveryEntry { + const parsed = entrySchema.parse(value) + const marker = parseMarker(parsed.marker) + const replacement = parsed.replacement === undefined ? undefined : parseMarker(parsed.replacement) + if (replacement && replacement.sessionId !== marker.sessionId) { + throw new Error('agent_session_recovery_capsule_invalid') + } + if (parsed.state === 'pending') { + return { state: 'pending', marker, ...(replacement ? { replacement } : {}) } + } + if (parsed.operationId === undefined || parsed.startedAt === undefined) { + throw new Error('agent_session_recovery_capsule_invalid') + } + return { + state: 'in-progress', + operationId: parsed.operationId, + startedAt: parsed.startedAt, + marker, + ...(replacement ? { replacement } : {}) + } +} + +// Failures are advisory, so one this build cannot read (say, a newer outcome) is dropped, and gone +// after the next write, rather than costing every offer and every later teardown record. +function parseFailures(value: unknown): AgentSessionResumeFailureRecord[] { + return (Array.isArray(value) ? value : []).flatMap((failure: unknown) => { + const parsed = failureSchema.safeParse(failure) + const marker = parsed.success ? parseAgentSessionResumeMarker(parsed.data.marker) : null + return parsed.success && marker ? [{ ...parsed.data, marker }] : [] + }) +} + +export function parseState(raw: string): RecoveryCapsuleState { + const value: unknown = JSON.parse(raw) + const legacy = legacyCapsuleSchema.safeParse(value) + if (legacy.success) { + return { + entries: legacy.data.markers.map((marker) => ({ + state: 'pending', + marker: parseMarker(marker) + })), + failed: [] + } + } + const capsule = capsuleSchema.parse(value) + return { + entries: capsule.entries.map(parseEntry), + failed: parseFailures(capsule.failed), + ...(capsule.dismissedAt === undefined ? {} : { dismissedAt: capsule.dismissedAt }) + } +} + +function sameWitness(left: AgentSessionResumeMarker, right: AgentSessionResumeMarker): boolean { + return left.teardownId === right.teardownId && left.recordedAt === right.recordedAt +} + +export function normalizeState( + state: RecoveryCapsuleState, + now: number +): Pick { + const bySession = new Map() + for (const entry of state.entries) { + const replacement = + entry.replacement && !isExpiredAgentSessionResumeMarker(entry.replacement, now) + ? entry.replacement + : undefined + if (isExpiredAgentSessionResumeMarker(entry.marker, now) && replacement === undefined) { + continue + } + if (bySession.has(entry.marker.sessionId)) { + throw new Error('agent_session_recovery_capsule_duplicate_session') + } + const reclaimed = + entry.state === 'in-progress' && + entry.startedAt !== undefined && + now - entry.startedAt > RESUME_ACTION_LEASE_TTL_MS + const normalized: RecoveryEntry = + entry.state === 'in-progress' && reclaimed + ? { state: 'pending', marker: replacement ?? entry.marker } + : entry.state === 'pending' && replacement + ? { state: 'pending', marker: replacement } + : replacement + ? { ...entry, replacement } + : entry + bySession.set(normalized.marker.sessionId, normalized) + } + const failed: AgentSessionResumeFailureRecord[] = [] + for (const failure of state.failed) { + if (isExpiredAgentSessionResumeMarker(failure.marker, now)) { + continue + } + if (failed.some((kept) => kept.marker.sessionId === failure.marker.sessionId)) { + throw new Error('agent_session_recovery_capsule_duplicate_session') + } + const entry = bySession.get(failure.marker.sessionId) + if (entry?.state === 'pending') { + if (!sameWitness(entry.marker, failure.marker)) { + // A newer teardown of the same chat: it was working again, so the old verdict is stale. + continue + } + // A retry of this failure that rolled back or whose lease lapsed. It stays a failure, never a + // pending offer that an unselective "resume all" would silently re-run. + bySession.delete(failure.marker.sessionId) + } + failed.push(failure) + } + return { entries: [...bySession.values()], failed } +} + +export function shouldReplaceMarker( + current: AgentSessionResumeMarker, + incoming: AgentSessionResumeMarker +): boolean { + if (incoming.recordedAt !== current.recordedAt) { + return incoming.recordedAt > current.recordedAt + } + // A single teardown may publish the same witness more than once. Different teardown IDs at the + // same clock value have no ordering signal, so keep the first one rather than let a late writer + // regress a newer witness from another host. + return incoming.teardownId === current.teardownId +} diff --git a/src/main/runtime/agent-session-recovery-capsule.test.ts b/src/main/runtime/agent-session-recovery-capsule.test.ts index 75de1d285ad..d3efa013aa8 100644 --- a/src/main/runtime/agent-session-recovery-capsule.test.ts +++ b/src/main/runtime/agent-session-recovery-capsule.test.ts @@ -2,6 +2,7 @@ import { mkdtemp, readFile, readdir, rm, utimes, writeFile } from 'node:fs/promi import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { z } from 'zod' import { AGENT_SESSION_RESUME_MARKER_TTL_MS } from '../../shared/agent-session-resume-marker' import * as durable from '../durable-file-write' import { @@ -11,7 +12,8 @@ import { } from '../native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness' import { AgentSessionRecoveryCapsule, - AGENT_SESSION_RECOVERY_CAPSULE_FILE + AGENT_SESSION_RECOVERY_CAPSULE_FILE, + type AgentSessionResumeFailureInput } from './agent-session-recovery-capsule' let directory: string @@ -29,6 +31,25 @@ afterEach(async () => { await rm(directory, { recursive: true, force: true }) }) +function failure( + overrides: Partial = {} +): AgentSessionResumeFailureInput { + return { + sessionId: SESSION, + failedAt: NOW, + outcome: 'refused', + reason: 'agent_session_restart_work_superseded', + latestPrompt: '', + latestUserItemId: 'user-after-failure', + ...overrides + } +} + +async function fileFailure(overrides: Partial = {}) { + await capsule.beginResume([SESSION], 'operation-a', NOW) + await capsule.failResume('operation-a', [failure(overrides)], NOW) +} + function failPublish() { return vi.spyOn(durable, 'renameDurable').mockRejectedValueOnce(new Error('publish unavailable')) } @@ -89,6 +110,230 @@ describe('durable restart offers', () => { expect(await capsule.list(NOW)).toEqual([marker({ sessionId: 'second' })]) }) + it('files an acted-on session whose agent did not carry on as a durable failure', async () => { + await capsule.record([marker(), marker({ sessionId: 'second' })], NOW) + await capsule.beginResume([SESSION], 'operation-a', NOW) + + await capsule.failResume( + 'operation-a', + [ + failure({ + failedAt: NOW + 5, + reason: 'agent_session_restart_work_superseded', + latestPrompt: 'fix the auth bug' + }) + ], + NOW + 5 + ) + + // No longer an offer, but still on record with what went wrong. + expect(await capsule.list(NOW + 5)).toEqual([marker({ sessionId: 'second' })]) + expect(await capsule.listFailed(NOW + 5)).toEqual([ + { + marker: marker(), + failedAt: NOW + 5, + outcome: 'refused', + reason: 'agent_session_restart_work_superseded', + latestPrompt: 'fix the auth bug', + latestUserItemId: 'user-after-failure' + } + ]) + // Still there after the operation's own rollback: a failure is settled, not reopened. + await capsule.rollbackResume('operation-a', NOW + 5) + expect(await capsule.list(NOW + 5)).toEqual([marker({ sessionId: 'second' })]) + expect(await capsule.listFailed(NOW + 5)).toHaveLength(1) + }) + + it('only files failures for the operation that owns the reservation', async () => { + await capsule.record([marker()], NOW) + await capsule.beginResume([SESSION], 'operation-a', NOW) + await capsule.failResume('operation-b', [failure()], NOW) + expect(await capsule.listFailed(NOW)).toEqual([]) + await capsule.rollbackResume('operation-a', NOW) + expect(await capsule.list(NOW)).toEqual([marker()]) + }) + + it('retries a failure only when the action names it, and a success removes it', async () => { + await capsule.record([marker(), marker({ sessionId: 'second' })], NOW) + await fileFailure() + + // Resume-all must not silently re-run what already failed. + expect(await capsule.beginResume(undefined, 'operation-b', NOW)).toEqual([ + marker({ sessionId: 'second' }) + ]) + await capsule.rollbackResume('operation-b', NOW) + // Naming it is a retry. The failure stays on record until the retry settles. + expect(await capsule.beginResume([SESSION], 'operation-c', NOW)).toEqual([marker()]) + expect(await capsule.listFailed(NOW)).toHaveLength(1) + await capsule.completeResume('operation-c', [SESSION], NOW) + expect(await capsule.listFailed(NOW)).toEqual([]) + expect(await capsule.list(NOW)).toEqual([marker({ sessionId: 'second' })]) + }) + + it('keeps a retried failure a failure when the retry rolls back or its lease lapses', async () => { + await capsule.record([marker()], NOW) + await fileFailure() + + await capsule.beginResume([SESSION], 'operation-b', NOW) + await capsule.rollbackResume('operation-b', NOW) + expect(await capsule.list(NOW)).toEqual([]) + expect(await capsule.listFailed(NOW)).toHaveLength(1) + expect(await capsule.beginResume(undefined, 'operation-c', NOW)).toEqual([]) + + await capsule.beginResume([SESSION], 'operation-d', NOW) + const later = NOW + 10 * 60 * 1000 + 1 + expect(await capsule.list(later)).toEqual([]) + expect(await capsule.beginResume(undefined, 'operation-e', later)).toEqual([]) + expect(await capsule.listFailed(later)).toHaveLength(1) + }) + + it('refiles a failed retry with its new reason', async () => { + await capsule.record([marker()], NOW) + await fileFailure() + await capsule.beginResume([SESSION], 'operation-b', NOW + 1) + await capsule.failResume( + 'operation-b', + [failure({ failedAt: NOW + 1, reason: 'agent_session_conflict' })], + NOW + 1 + ) + + expect(await capsule.listFailed(NOW + 1)).toMatchObject([ + { failedAt: NOW + 1, reason: 'agent_session_conflict' } + ]) + }) + + it('lets a newer teardown of the same chat supersede its recorded failure', async () => { + await capsule.record([marker()], NOW) + await fileFailure({ outcome: 'unconfirmed', reason: 'pending' }) + // A late writer of an older or the same witness does not. + await capsule.record([marker()], NOW) + expect(await capsule.list(NOW)).toEqual([]) + expect(await capsule.listFailed(NOW)).toHaveLength(1) + + const newer = marker({ recordedAt: NOW + 1, teardownId: 'teardown-new' }) + await capsule.record([newer], NOW + 1) + + expect(await capsule.list(NOW + 1)).toEqual([newer]) + expect(await capsule.listFailed(NOW + 1)).toEqual([]) + }) + + it('forgets a superseded failure only while it is the one that was read', async () => { + await capsule.record([marker()], NOW) + await fileFailure() + + await capsule.forgetFailures([{ sessionId: SESSION, failedAt: NOW - 1 }], NOW) + expect(await capsule.listFailed(NOW)).toHaveLength(1) + await capsule.forgetFailures([{ sessionId: SESSION, failedAt: NOW }], NOW) + expect(await capsule.listFailed(NOW)).toEqual([]) + }) + + it('forgets named records of any state and reports how many went', async () => { + await capsule.record( + [marker(), marker({ sessionId: 'second' }), marker({ sessionId: 'third' })], + NOW + ) + await fileFailure() + const before = await readFile(filePath) + + expect(await capsule.dismiss([SESSION, 'second', 'missing'], NOW)).toBe(2) + expect(await capsule.list(NOW)).toEqual([marker({ sessionId: 'third' })]) + expect(await capsule.listFailed(NOW)).toEqual([]) + // Nothing named, nothing rewritten. + expect(await capsule.dismiss(['missing'], NOW)).toBe(0) + expect(await readFile(filePath)).not.toEqual(before) + // Not a fence: the same chat may be offered again by a later teardown. + await capsule.record([marker()], NOW) + expect(await capsule.list(NOW)).toEqual([marker({ sessionId: 'third' }), marker()]) + }) + + it('expires a recorded failure with its marker', async () => { + await capsule.record([marker()], NOW) + await fileFailure() + expect(await capsule.listFailed(NOW + AGENT_SESSION_RESUME_MARKER_TTL_MS)).toHaveLength(1) + expect(await capsule.listFailed(NOW + AGENT_SESSION_RESUME_MARKER_TTL_MS + 1)).toEqual([]) + }) + + // An older build parses entries with a two-state enum and throws on anything else, which would + // cost it every offer. Its schema ignores unknown top-level keys, so failures live under one. + it('writes failures in a file an older build still reads its offers from', async () => { + const olderEntry = z.object({ + state: z.enum(['pending', 'in-progress']), + operationId: z.string().min(1).optional(), + startedAt: z.number().int().nonnegative().optional(), + marker: z.unknown(), + replacement: z.unknown().optional() + }) + const olderCapsule = z.object({ + version: z.literal(2), + entries: z.array(z.unknown()), + dismissedAt: z.number().int().nonnegative().optional() + }) + const olderStates = async () => + olderCapsule + .parse(JSON.parse(await readFile(filePath, 'utf8'))) + .entries.map((entry) => olderEntry.parse(entry).state) + await capsule.record([marker(), marker({ sessionId: 'second' })], NOW) + + await fileFailure() + expect(await olderStates()).toEqual(['pending']) + await capsule.beginResume([SESSION], 'operation-retry', NOW) + expect((await olderStates()).sort()).toEqual(['in-progress', 'pending']) + expect(JSON.parse(await readFile(filePath, 'utf8'))).toMatchObject({ + failed: [{ marker: { sessionId: SESSION } }] + }) + }) + + // A newer build may file a failure shape this one cannot parse; after a downgrade that must not + // cost the offers or block recording new teardowns. + it('skips a failure record it cannot read instead of rejecting the whole file', async () => { + const readable = { + marker: marker(), + failedAt: NOW, + outcome: 'refused', + reason: 'agent_session_restart_work_superseded', + latestPrompt: '', + latestUserItemId: null + } + await writeFile( + filePath, + JSON.stringify({ + version: 2, + entries: [{ state: 'pending', marker: marker({ sessionId: 'second' }) }], + failed: [ + { ...readable, marker: marker({ sessionId: 'future' }), outcome: 'later-outcome' }, + { ...readable, marker: { ...marker({ sessionId: 'future-marker' }), trigger: 'later' } }, + readable + ] + }) + ) + + expect(await capsule.list(NOW)).toEqual([marker({ sessionId: 'second' })]) + expect(await capsule.listFailed(NOW)).toEqual([readable]) + await capsule.record([marker({ sessionId: 'third' })], NOW) + expect(await capsule.list(NOW)).toEqual([ + marker({ sessionId: 'second' }), + marker({ sessionId: 'third' }) + ]) + expect(await capsule.listFailed(NOW)).toEqual([readable]) + expect(JSON.parse(await readFile(filePath, 'utf8')).failed).toEqual([readable]) + }) + + it('reads a malformed failure list as no failures', async () => { + await writeFile( + filePath, + JSON.stringify({ + version: 2, + entries: [{ state: 'pending', marker: marker() }], + failed: { unexpected: true } + }) + ) + + expect(await capsule.list(NOW)).toEqual([marker()]) + expect(await capsule.listFailed(NOW)).toEqual([]) + await capsule.record([marker({ sessionId: 'second' })], NOW) + expect(await capsule.list(NOW)).toEqual([marker(), marker({ sessionId: 'second' })]) + }) + it('rolls a failed acquisition back to a pending offer', async () => { await capsule.record([marker()], NOW) await capsule.beginResume([SESSION], 'operation-a', NOW) diff --git a/src/main/runtime/agent-session-recovery-capsule.ts b/src/main/runtime/agent-session-recovery-capsule.ts index c7575749288..6dbb4be5a8a 100644 --- a/src/main/runtime/agent-session-recovery-capsule.ts +++ b/src/main/runtime/agent-session-recovery-capsule.ts @@ -1,10 +1,8 @@ import { rm } from 'node:fs/promises' import { join } from 'node:path' -import { z } from 'zod' import { AGENT_SESSION_RESUME_MARKER_TTL_MS, isExpiredAgentSessionResumeMarker, - parseAgentSessionResumeMarker, type AgentSessionResumeMarker } from '../../shared/agent-session-resume-marker' import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader' @@ -16,128 +14,26 @@ import { writeTempFileDurable } from '../durable-file-write' import { withFileTransactionLock } from '../file-transaction-lock' +import { + MAX_FAILURE_FIELD_LENGTH, + normalizeState, + parseState, + shouldReplaceMarker, + type AgentSessionResumeFailureInput, + type AgentSessionResumeFailureRecord, + type RecoveryCapsuleState, + type RecoveryEntry +} from './agent-session-recovery-capsule-entries' + +export type { + AgentSessionResumeFailureInput, + AgentSessionResumeFailureRecord +} from './agent-session-recovery-capsule-entries' export const AGENT_SESSION_RECOVERY_CAPSULE_FILE = 'agent-session-recovery.json' const MAX_CAPSULE_BYTES = 4 * 1024 * 1024 -const RESUME_ACTION_LEASE_TTL_MS = 10 * 60 * 1000 -const legacyCapsuleSchema = z.object({ version: z.literal(1), markers: z.array(z.unknown()) }) -const entrySchema = z.object({ - state: z.enum(['pending', 'in-progress']), - operationId: z.string().min(1).optional(), - startedAt: z.number().int().nonnegative().optional(), - marker: z.unknown(), - replacement: z.unknown().optional() -}) -const capsuleSchema = z.object({ - version: z.literal(2), - entries: z.array(z.unknown()), - dismissedAt: z.number().int().nonnegative().optional() -}) - -type RecoveryEntry = { - state: 'pending' | 'in-progress' - operationId?: string - startedAt?: number - marker: AgentSessionResumeMarker - replacement?: AgentSessionResumeMarker -} - -type RecoveryCapsuleState = { - entries: RecoveryEntry[] - dismissedAt?: number -} - -function parseMarker(value: unknown): AgentSessionResumeMarker { - const marker = parseAgentSessionResumeMarker(value) - if (!marker) { - throw new Error('agent_session_recovery_capsule_invalid') - } - return marker -} - -function parseState(raw: string): RecoveryCapsuleState { - const value: unknown = JSON.parse(raw) - const legacy = legacyCapsuleSchema.safeParse(value) - if (legacy.success) { - return { - entries: legacy.data.markers.map((marker) => ({ - state: 'pending', - marker: parseMarker(marker) - })) - } - } - const capsule = capsuleSchema.parse(value) - const entries = capsule.entries.map((entry) => { - const parsed = entrySchema.parse(entry) - const marker = parseMarker(parsed.marker) - const replacement = - parsed.replacement === undefined ? undefined : parseMarker(parsed.replacement) - if (replacement && replacement.sessionId !== marker.sessionId) { - throw new Error('agent_session_recovery_capsule_invalid') - } - if (parsed.state === 'pending') { - return { state: 'pending' as const, marker, ...(replacement ? { replacement } : {}) } - } - if (parsed.operationId === undefined || parsed.startedAt === undefined) { - throw new Error('agent_session_recovery_capsule_invalid') - } - return { - state: 'in-progress' as const, - operationId: parsed.operationId, - startedAt: parsed.startedAt, - marker, - ...(replacement ? { replacement } : {}) - } - }) - return { - entries, - ...(capsule.dismissedAt === undefined ? {} : { dismissedAt: capsule.dismissedAt }) - } -} - -function normalizeEntries(entries: readonly RecoveryEntry[], now: number): RecoveryEntry[] { - const bySession = new Map() - for (const entry of entries) { - const replacement = - entry.replacement && !isExpiredAgentSessionResumeMarker(entry.replacement, now) - ? entry.replacement - : undefined - if (isExpiredAgentSessionResumeMarker(entry.marker, now) && replacement === undefined) { - continue - } - if (bySession.has(entry.marker.sessionId)) { - throw new Error('agent_session_recovery_capsule_duplicate_session') - } - const reclaimed = - entry.state === 'in-progress' && - entry.startedAt !== undefined && - now - entry.startedAt > RESUME_ACTION_LEASE_TTL_MS - const normalized: RecoveryEntry = - entry.state === 'in-progress' && reclaimed - ? { state: 'pending', marker: replacement ?? entry.marker } - : entry.state === 'pending' && replacement - ? { state: 'pending', marker: replacement } - : replacement - ? { ...entry, replacement } - : entry - bySession.set(normalized.marker.sessionId, normalized) - } - return [...bySession.values()] -} - -function shouldReplaceMarker( - current: AgentSessionResumeMarker, - incoming: AgentSessionResumeMarker -): boolean { - if (incoming.recordedAt !== current.recordedAt) { - return incoming.recordedAt > current.recordedAt - } - // A single teardown may publish the same witness more than once. Different teardown IDs at the - // same clock value have no ordering signal, so keep the first one rather than let a late writer - // regress a newer witness from another host. - return incoming.teardownId === current.teardownId -} +type StoredRecords = Pick /** Durable, per-session restart offers. Listing never spends an offer. */ export class AgentSessionRecoveryCapsule { @@ -149,17 +45,26 @@ export class AgentSessionRecoveryCapsule { list(now: number): Promise { return withFileTransactionLock(this.filePath, async () => { - const entries = normalizeEntries((await this.readState()).entries, now) + const { entries } = normalizeState(await this.readState(), now) return entries.filter((entry) => entry.state === 'pending').map((entry) => entry.marker) }) } + /** Offers that were acted on and did not end with the agent carrying on. Read-only, like `list`. */ + listFailed(now: number): Promise { + return withFileTransactionLock( + this.filePath, + async () => normalizeState(await this.readState(), now).failed + ) + } + /** Adds fresh teardown witnesses while preserving an action already in progress. */ record(markers: readonly AgentSessionResumeMarker[], now: number): Promise { return withFileTransactionLock(this.filePath, async () => { const state = await this.readState() - const entries = normalizeEntries(state.entries, now) + const { entries, failed } = normalizeState(state, now) const bySession = new Map(entries.map((entry) => [entry.marker.sessionId, entry])) + const failedBySession = new Map(failed.map((failure) => [failure.marker.sessionId, failure])) const dismissedAt = state.dismissedAt for (const marker of markers) { if ( @@ -179,18 +84,26 @@ export class AgentSessionRecoveryCapsule { } continue } - if (existing && !shouldReplaceMarker(existing.marker, marker)) { + const failure = failedBySession.get(marker.sessionId) + if ( + (existing && !shouldReplaceMarker(existing.marker, marker)) || + (failure && !shouldReplaceMarker(failure.marker, marker)) + ) { continue } + // A newer witness than a recorded failure supersedes it when the records are normalized. bySession.set(marker.sessionId, { state: 'pending', marker }) } // Keep the fence after a newer interruption. It still admits genuinely newer markers, // while an older delayed writer remains unable to resurrect a dismissed chat later. - await this.publish([...bySession.values()], dismissedAt) + await this.publish({ entries: [...bySession.values()], failed }, now, dismissedAt) }) } - /** Reserves only the selected pending sessions for one explicit user action. */ + /** Reserves only the selected pending sessions for one explicit user action. A recorded failure + * is reserved too when the action names it — that is a retry — but never by an unselective + * action, which must not re-run what already failed. The failure stays on record until the + * retry settles. */ beginResume( sessionIds: readonly string[] | undefined, operationId: string, @@ -198,52 +111,157 @@ export class AgentSessionRecoveryCapsule { ): Promise { return withFileTransactionLock(this.filePath, async () => { const state = await this.readState() - const entries = normalizeEntries(state.entries, now) + const { entries, failed } = normalizeState(state, now) const requested = sessionIds === undefined ? null : new Set(sessionIds) const selected: AgentSessionResumeMarker[] = [] - const next = entries.map((entry) => { - if ( - entry.state !== 'pending' || - (requested !== null && !requested.has(entry.marker.sessionId)) - ) { - return entry + const reserve = (marker: AgentSessionResumeMarker): RecoveryEntry => { + selected.push(marker) + return { state: 'in-progress', operationId, startedAt: now, marker } + } + const next = entries.map((entry) => + entry.state === 'pending' && (requested === null || requested.has(entry.marker.sessionId)) + ? reserve(entry.marker) + : entry + ) + const held = new Set(entries.map((entry) => entry.marker.sessionId)) + for (const failure of failed) { + if (requested?.has(failure.marker.sessionId) && !held.has(failure.marker.sessionId)) { + next.push(reserve(failure.marker)) } - selected.push(entry.marker) - return { state: 'in-progress' as const, operationId, startedAt: now, marker: entry.marker } - }) - await this.publish(next, state.dismissedAt) + } + await this.publish({ entries: next, failed }, now, state.dismissedAt) return selected }) } + /** The agent carried on: the reservation and any failure it was retrying both go. */ completeResume(operationId: string, sessionIds: readonly string[], now: number): Promise { return withFileTransactionLock(this.filePath, async () => { const selected = new Set(sessionIds) const state = await this.readState() - const entries = normalizeEntries(state.entries, now).flatMap((entry) => { - if ( - entry.state !== 'in-progress' || - entry.operationId !== operationId || - !selected.has(entry.marker.sessionId) - ) { + const { entries, failed } = normalizeState(state, now) + const completed = new Set() + const next = entries.flatMap((entry) => { + if (!this.owns(entry, operationId) || !selected.has(entry.marker.sessionId)) { return [entry] } + completed.add(entry.marker.sessionId) return entry.replacement ? [{ state: 'pending' as const, marker: entry.replacement }] : [] }) - await this.publish(entries, state.dismissedAt) + await this.publish( + { + entries: next, + failed: failed.filter((failure) => !completed.has(failure.marker.sessionId)) + }, + now, + state.dismissedAt + ) + }) + } + + /** Records how a reserved session's action ended when the agent did not carry on, replacing any + * earlier failure of the same chat. Only rows this operation owns move, so a competing owner's + * reservation cannot be settled by proxy. */ + failResume( + operationId: string, + failures: readonly AgentSessionResumeFailureInput[], + now: number + ): Promise { + return withFileTransactionLock(this.filePath, async () => { + const bySession = new Map(failures.map((failure) => [failure.sessionId, failure])) + const state = await this.readState() + const { entries, failed } = normalizeState(state, now) + const filed = new Map() + const next = entries.flatMap((entry) => { + const failure = this.owns(entry, operationId) + ? bySession.get(entry.marker.sessionId) + : undefined + if (!failure) { + return [entry] + } + const { sessionId, ...record } = failure + filed.set(sessionId, { + ...record, + marker: entry.marker, + reason: record.reason.slice(0, MAX_FAILURE_FIELD_LENGTH), + latestPrompt: record.latestPrompt.slice(0, MAX_FAILURE_FIELD_LENGTH) + }) + // A newer teardown seen mid-action is a fresh offer; normalizing drops the stale verdict. + return entry.replacement ? [{ state: 'pending' as const, marker: entry.replacement }] : [] + }) + await this.publish( + { + entries: next, + failed: [ + ...failed.filter((failure) => !filed.has(failure.marker.sessionId)), + ...filed.values() + ] + }, + now, + state.dismissedAt + ) + }) + } + + /** Forgets the named sessions whatever their state. Unlike `clearAll`, this is not a fence: a + * later teardown of the same chat may record a fresh offer. */ + dismiss(sessionIds: readonly string[], now: number): Promise { + return withFileTransactionLock(this.filePath, async () => { + const named = new Set(sessionIds) + const state = await this.readState() + const { entries, failed } = normalizeState(state, now) + const dismissed = new Set( + [...entries, ...failed] + .map((record) => record.marker.sessionId) + .filter((sessionId) => named.has(sessionId)) + ) + if (dismissed.size > 0) { + await this.publish( + { + entries: entries.filter((entry) => !dismissed.has(entry.marker.sessionId)), + failed: failed.filter((failure) => !dismissed.has(failure.marker.sessionId)) + }, + now, + state.dismissedAt + ) + } + return dismissed.size + }) + } + + /** Drops failure records the chat itself has since superseded. Keyed by filing time as well, so a + * failure refiled after the caller read the old one is kept. */ + forgetFailures( + superseded: readonly { sessionId: string; failedAt: number }[], + now: number + ): Promise { + return withFileTransactionLock(this.filePath, async () => { + const state = await this.readState() + const { entries, failed } = normalizeState(state, now) + const kept = failed.filter( + (failure) => + !superseded.some( + (gone) => + gone.sessionId === failure.marker.sessionId && gone.failedAt === failure.failedAt + ) + ) + if (kept.length !== failed.length) { + await this.publish({ entries, failed: kept }, now, state.dismissedAt) + } }) } rollbackResume(operationId: string, now: number): Promise { return withFileTransactionLock(this.filePath, async () => { const state = await this.readState() - const entries = normalizeEntries(state.entries, now).flatMap((entry) => { - if (entry.state !== 'in-progress' || entry.operationId !== operationId) { - return [entry] - } - return [{ state: 'pending' as const, marker: entry.replacement ?? entry.marker }] - }) - await this.publish(entries, state.dismissedAt) + const { entries, failed } = normalizeState(state, now) + const next = entries.map((entry): RecoveryEntry => + this.owns(entry, operationId) + ? { state: 'pending', marker: entry.replacement ?? entry.marker } + : entry + ) + // Normalizing on publish keeps a rolled-back retry a failure rather than a pending offer. + await this.publish({ entries: next, failed }, now, state.dismissedAt) }) } @@ -251,22 +269,26 @@ export class AgentSessionRecoveryCapsule { return withFileTransactionLock(this.filePath, async () => { let entries: RecoveryEntry[] try { - entries = normalizeEntries((await this.readState()).entries, now) + entries = normalizeState(await this.readState(), now).entries } catch { // Dismiss is an explicit request to forget this advisory file. Replace unreadable bytes // with an empty, fenced capsule so a late teardown writer cannot resurrect the offer. - await this.publish([], now) + await this.publish({ entries: [], failed: [] }, now, now) return 0 } const pending = entries.filter((entry) => entry.state === 'pending') // Dismiss is the explicit user request to forget every recovery record. An in-flight // action may still finish, but its later complete/rollback becomes a no-op and cannot // resurrect a row the user dismissed. - await this.publish([], now) + await this.publish({ entries: [], failed: [] }, now, now) return pending.length }) } + private owns(entry: RecoveryEntry, operationId: string): boolean { + return entry.state === 'in-progress' && entry.operationId === operationId + } + private async readState(): Promise { let raw: string try { @@ -275,16 +297,26 @@ export class AgentSessionRecoveryCapsule { ) } catch (error) { if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - return { entries: [] } + return { entries: [], failed: [] } } throw error } return parseState(raw) } - private async publish(entries: readonly RecoveryEntry[], dismissedAt?: number): Promise { + private async publish( + records: StoredRecords, + now: number, + dismissedAt: number | undefined + ): Promise { + const { entries, failed } = normalizeState(records, now) const { serialized } = stringifyJsonWithinByteLimit( - { version: 2, entries, ...(dismissedAt === undefined ? {} : { dismissedAt }) }, + { + version: 2, + entries, + ...(dismissedAt === undefined ? {} : { dismissedAt }), + ...(failed.length === 0 ? {} : { failed }) + }, MAX_CAPSULE_BYTES ) await removeStaleDurableWriteTempFiles(this.filePath, { diff --git a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts index 36d7e35eb5e..f3e24aba63f 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-restart-resume.ts @@ -19,7 +19,12 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ params: RestartResumableParams, handler: async (_params, ctx) => { await ensureStructuredHostInstalled(ctx) - return { sessions: await requireStructuredHost(ctx).restartResume.list() } + const host = requireStructuredHost(ctx) + return { + sessions: await host.restartResume.list(), + // Acted-on offers whose agent did not carry on. Optional on the wire; older clients ignore it. + failed: await host.restartResume.listFailures() + } } }), defineMethod({ @@ -27,13 +32,20 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ // not call this method, so the status-bar entry can reopen the offer later. name: 'agentSession.restartResumableDismiss', params: RestartResumableParams, - handler: async (_params, ctx) => { + handler: async (params, ctx) => { await ensureStructuredHostInstalled(ctx) const host = requireStructuredHost(ctx) - const dismissed = await host.restartResume.dismiss() - // clearAll is the authoritative mutation: it removes pending and in-flight records, so a - // second read would only add a new failure point after the user's explicit dismissal. - return { dismissed, sessions: [] } + const dismissed = await host.restartResume.dismiss(params.sessionIds) + if (params.sessionIds === undefined) { + // clearAll is the authoritative mutation: it removes pending and in-flight records, so a + // second read would only add a new failure point after the user's explicit dismissal. + return { dismissed, sessions: [], failed: [] } + } + return { + dismissed, + sessions: await host.restartResume.list(), + failed: await host.restartResume.listFailures() + } } }), defineMethod({ diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index d68bd8454fc..e0d1791606b 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -104,6 +104,9 @@ --color-status-success: var(--status-success); --color-status-success-background: var(--status-success-background); --color-status-success-border: var(--status-success-border); + --color-status-warning: var(--status-warning); + --color-status-warning-background: var(--status-warning-background); + --color-status-warning-border: var(--status-warning-border); --color-workspace-status-done: var(--workspace-status-done); --color-workspace-status-review: var(--workspace-status-review); --color-workspace-status-progress: var(--workspace-status-progress); @@ -185,6 +188,11 @@ --status-success: #15803d; --status-success-background: color-mix(in srgb, var(--status-success) 10%, transparent); --status-success-border: color-mix(in srgb, var(--status-success) 25%, transparent); + /* Needs-attention state that is not an error: a resume that did not carry on, an update that + failed. Yellow, so it stays apart from destructive red and from the orange working state. */ + --status-warning: #ca8a04; + --status-warning-background: color-mix(in srgb, var(--status-warning) 10%, transparent); + --status-warning-border: color-mix(in srgb, var(--status-warning) 25%, transparent); /* Workspace PR-state tones: same hue in both themes so a status keeps its identity when the theme flips. */ --workspace-status-done: #c7a594; @@ -299,6 +307,9 @@ --status-success: #86efac; --status-success-background: color-mix(in srgb, var(--status-success) 10%, transparent); --status-success-border: color-mix(in srgb, var(--status-success) 25%, transparent); + --status-warning: #eab308; + --status-warning-background: color-mix(in srgb, var(--status-warning) 10%, transparent); + --status-warning-border: color-mix(in srgb, var(--status-warning) 25%, transparent); --workspace-status-done: #c7a594; --workspace-status-review: #16a34a; --workspace-status-progress: #d4a300; diff --git a/src/renderer/src/components/NativeChatResumeFailureDetails.tsx b/src/renderer/src/components/NativeChatResumeFailureDetails.tsx new file mode 100644 index 00000000000..8e7f45437da --- /dev/null +++ b/src/renderer/src/components/NativeChatResumeFailureDetails.tsx @@ -0,0 +1,123 @@ +import { AlertCircle, Clock, X } from 'lucide-react' +import { Button } from './ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip' +import { translate } from '@/i18n/i18n' +import { + resumeFailureGuidance, + type ResumeFailureAction +} from './native-chat-resume-failure-guidance' +import type { ResumeFailure } from './native-chat-resume-on-restart-grouping' + +/** + * What a failed row adds to an ordinary offered row: one status icon whose tooltip carries the + * status and the host's reason, a dismiss control, and a "To resume" line with the button that does + * it. Retry is offered only where a retry can succeed. + */ + +function actionLabel(action: ResumeFailureAction): string { + return action === 'open' + ? translate('auto.components.NativeChatResumeOutcomeRow.openChat', 'Open chat') + : action === 'retry' + ? translate('auto.components.NativeChatResumeOutcomeRow.retry', 'Retry') + : translate('auto.components.NativeChatResumeOutcomeRow.dismiss', 'Dismiss') +} + +export function ResumeFailureStatus({ + failure, + title, + workspaceName, + disabled, + onAction +}: { + failure: ResumeFailure + title: string + workspaceName: string + disabled: boolean + onAction: (action: ResumeFailureAction) => void +}): React.JSX.Element { + const status = + failure.outcome === 'unconfirmed' + ? translate( + 'auto.components.NativeChatResumeOutcomeRow.unconfirmed', + 'Couldn’t confirm the chat was resumed' + ) + : translate('auto.components.NativeChatResumeOutcomeRow.failed', 'Couldn’t resume') + return ( + <> + + + {/* Focusable so keyboard users reach the same tooltip; the accessible name says which chat. */} + + {failure.outcome === 'unconfirmed' ? ( + + ) : ( + + )} + + + + {status} + {/* The code verbatim, so it can be quoted in a report. */} + {failure.reason} + + + + + ) +} + +export function ResumeFailureGuidanceLine({ + failure, + disabled, + onAction +}: { + failure: ResumeFailure + disabled: boolean + onAction: (action: ResumeFailureAction) => void +}): React.JSX.Element { + const guidance = resumeFailureGuidance(failure) + return ( +
+ + + {translate('auto.components.NativeChatResumeOutcomeRow.toResume', 'To resume:')} + {' '} + {guidance.text} + + + {guidance.secondary && ( + + )} +
+ ) +} diff --git a/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx b/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx index a6dba8394ae..3690776f49d 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx @@ -3,7 +3,12 @@ import { AgentIcon } from '@/lib/agent-catalog' import { agentTypeToIconAgent, formatAgentTypeLabel } from '@/lib/agent-status' import { formatShortTimeAgo } from '@/lib/short-time-ago' import { translate } from '@/i18n/i18n' -import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' +import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping' +import { + resumeFailureSelectable, + type ResumeFailureAction +} from './native-chat-resume-failure-guidance' +import { ResumeFailureGuidanceLine, ResumeFailureStatus } from './NativeChatResumeFailureDetails' /** * One offered chat, laid out like the sidebar's compact agent row: provider glyph, the chat's name, @@ -18,6 +23,9 @@ import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' * No state dot, deliberately. Every `AgentDotState` would mislead: `idle` and `unverifiable` both * presuppose a live pane, `interrupted` renders red like an error, `done` green, `working` a * spinner. A missing dot beats a dot that says these agents are running. + * + * A chat an earlier resume could not carry on is the same row — selectable where a retry can run, + * so Resume retries it — plus a status icon, a dismiss control, and a line saying what to do. */ export function ResumeCandidateRow({ candidate, @@ -25,7 +33,9 @@ export function ResumeCandidateRow({ listedAt, checked, disabled, - onCheckedChange + onCheckedChange, + failure, + onFailureAction }: { candidate: ResumeCandidate /** Named in the checkbox's accessible name: several rows otherwise read identically. */ @@ -34,46 +44,67 @@ export function ResumeCandidateRow({ checked: boolean disabled: boolean onCheckedChange: (checked: boolean) => void + /** Present when an earlier resume of this chat did not carry on. */ + failure?: ResumeFailure + onFailureAction?: (action: ResumeFailureAction, sessionId: string) => void }): React.JSX.Element { const agentLabel = formatAgentTypeLabel(candidate.agent) const title = candidate.latestPrompt.trim() || translate('auto.components.NativeChatResumeOnRestartModal.untitled', 'Untitled chat') const model = candidate.model?.trim() ?? '' - return ( -
  • - + ) + if (!failure) { + return
  • {row}
  • + } + const act = (action: ResumeFailureAction) => onFailureAction?.(action, candidate.sessionId) + return ( +
  • + {/* Outside the label, so pressing them never toggles the checkbox. */} +
    + {row} + +
    +
  • ) } diff --git a/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx b/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx index 2c6ebb1ce6a..6f8bb64f4f1 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx @@ -12,8 +12,10 @@ import { resolveResumeGroupHeader, resumeWorkspaceKind, type ResumeCandidate, + type ResumeFailure, type ResumeWorkspaceGroup } from './native-chat-resume-on-restart-grouping' +import type { ResumeFailureAction } from './native-chat-resume-failure-guidance' export type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' @@ -32,6 +34,12 @@ export type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' type StoreState = ReturnType +/** Lets a row that an earlier resume could not carry on show what went wrong and what to do. */ +type FailureProps = { + failureFor?: (sessionId: string) => ResumeFailure | undefined + onFailureAction?: (action: ResumeFailureAction, sessionId: string) => void +} + function resolveWorkspaceWorktree(store: StoreState, workspaceId: string) { return ( store.getKnownWorktreeById(workspaceId) ?? @@ -110,14 +118,16 @@ function WorkspaceGroup({ listedAt, busy, selected, - onToggle + onToggle, + failureFor, + onFailureAction }: { group: ResumeWorkspaceGroup listedAt: number busy: boolean selected: ReadonlySet onToggle: (sessionId: string, checked: boolean) => void -}): React.JSX.Element { +} & FailureProps): React.JSX.Element { const name = useWorkspaceName(group.workspaceId) const first = group.candidates[0] const kind = first ? resumeWorkspaceKind(first) : 'git-worktree' @@ -141,6 +151,8 @@ function WorkspaceGroup({ checked={selected.has(candidate.sessionId)} disabled={busy} onCheckedChange={(checked) => onToggle(candidate.sessionId, checked)} + failure={failureFor?.(candidate.sessionId)} + onFailureAction={onFailureAction} /> ))} @@ -153,14 +165,16 @@ export function ResumeOnRestartGroups({ listedAt, busy, selected, - onToggle + onToggle, + failureFor, + onFailureAction }: { candidates: readonly ResumeCandidate[] listedAt: number busy: boolean selected: ReadonlySet onToggle: (sessionId: string, checked: boolean) => void -}): React.JSX.Element { +} & FailureProps): React.JSX.Element { const workspaces = groupResumeCandidates(candidates) const repoIdFor = useRepoIdByWorkspace(workspaces.map((group) => group.workspaceId)) const repoGroups = groupResumeWorkspacesByRepo(workspaces, repoIdFor) @@ -178,6 +192,8 @@ export function ResumeOnRestartGroups({ busy={busy} selected={selected} onToggle={onToggle} + failureFor={failureFor} + onFailureAction={onFailureAction} /> ))} diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx index 977e76fbd79..e78e6f217d6 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx @@ -10,15 +10,25 @@ import { NativeChatResumeOnRestartModal } from './NativeChatResumeOnRestartModal import { NativeChatResumeStatusSegment } from './status-bar/NativeChatResumeStatusSegment' import { TooltipProvider } from './ui/tooltip' import type { ResumeCandidate } from './native-chat-resume-on-restart-grouping' -import { consumeNativeChatResumeOnRestartDialogRequest } from './native-chat-resume-on-restart-dialog' +import { + consumeNativeChatResumeOnRestartDialogRequest, + requestNativeChatResumeOnRestartDialog +} from './native-chat-resume-on-restart-dialog' import { _resetNativeChatRestartOffer, - getNativeChatRestartOffer + getNativeChatRestartOffer, + refreshNativeChatRestartOffer } from './native-chat-resume-on-restart-store' const rpc = vi.hoisted(() => vi.fn()) +const activate = vi.hoisted(() => vi.fn(async () => true)) vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: rpc + callStructuredAgentSession: rpc, + // A failed row opens the status feed; these cases never drive it. + subscribeStructuredAgentSessionStatus: () => new Promise(() => {}) +})) +vi.mock('@/lib/activate-ai-vault-structured-session', () => ({ + activateAiVaultStructuredSession: activate })) vi.mock('sonner', () => ({ toast: vi.fn() })) @@ -36,9 +46,34 @@ const offered: ResumeCandidate[] = ['a', 'b'].map((sessionId) => ({ workspaceKind: 'git-worktree' })) +/** A chat the host acted on and could not carry on, as it reports it. */ +function failure(sessionId: string, reason = 'agent_session_restart_work_superseded') { + const candidate = offered.find((entry) => entry.sessionId === sessionId)! + return { ...candidate, failedAt: candidate.recordedAt + 60_000, outcome: 'refused', reason } +} + +/** Outcome rows carry tooltips, so every mount needs the provider the app shell supplies. */ +async function mount(node: React.ReactNode): Promise { + await act(async () => root.render({node})) +} + +/** Sonner types a toast action as either a labelled action or arbitrary content; only the former + * can be pressed. */ +function press(entry: unknown): void { + if ( + typeof entry !== 'object' || + entry === null || + !('onClick' in entry) || + typeof entry.onClick !== 'function' + ) { + throw new Error('toast action is not clickable') + } + entry.onClick() +} + function button(text: string): HTMLButtonElement { const found = [...document.querySelectorAll('button')].find( - (entry) => entry.textContent?.trim() === text + (entry) => entry.textContent?.trim() === text || entry.getAttribute('aria-label') === text ) if (!found) { throw new Error(`Missing button: ${text}`) @@ -93,7 +128,7 @@ it('keeps next-launch preference out of the current resume action', async () => } return action.promise }) - await act(async () => root.render()) + await mount() await act(async () => checkbox(1).click()) await act(async () => checkbox(2).click()) await act(async () => button('Resume 1 chat').click()) @@ -115,7 +150,7 @@ it('keeps next-launch preference out of the current resume action', async () => // One primary action and one way out of it; the body copy carries the transparency. it('offers exactly Dismiss all and the resume action', async () => { rpc.mockResolvedValue({ sessions: offered }) - await act(async () => root.render()) + await mount() // Row and preference checkboxes are buttons too; the controls are what is left after them. const controls = document.querySelectorAll('[role="dialog"] button:not([role="checkbox"])') expect([...controls].map((entry) => entry.textContent?.trim())).toEqual([ @@ -129,7 +164,7 @@ it('offers exactly Dismiss all and the resume action', async () => { // other way out, and calls NOTHING — the offer is the host's and stays exactly where it was. it('snoozes to the status-bar offer when the dialog is closed', async () => { rpc.mockResolvedValue({ sessions: offered }) - await act(async () => root.render()) + await mount() await act(async () => checkbox(2).click()) await act(async () => button('Close').click()) expect(useAppStore.getState().settings?.nativeChatResumeWorkOnRestart).toBe(true) @@ -144,7 +179,7 @@ it('fully dismisses the offer only through Dismiss all', async () => { ? { sessions: offered } : { dismissed: 2, sessions: [] } ) - await act(async () => root.render()) + await mount() await act(async () => button('Dismiss all').click()) expect(rpc.mock.calls.map((call) => [call[1], call[2]])).toEqual([ ['agentSession.restartResumable', undefined], @@ -162,7 +197,7 @@ it('reports a dismissal the host never confirmed instead of trapping the dialog' } throw new Error('response lost') }) - await act(async () => root.render()) + await mount() await act(async () => button('Dismiss all').click()) expect(document.querySelector('[role="dialog"]')).toBeNull() expect(toast).toHaveBeenCalledWith(expect.stringContaining('was not confirmed')) @@ -174,7 +209,7 @@ it('saves Don’t ask again when the offer is dismissed outright', async () => { rpc.mockImplementation(async (_target, method) => method === 'agentSession.restartResumable' ? { sessions: offered } : { dismissed: 2 } ) - await act(async () => root.render()) + await mount() await act(async () => checkbox(2).click()) await act(async () => button('Dismiss all').click()) expect(useAppStore.getState().settings?.nativeChatResumeWorkOnRestart).toBe(true) @@ -196,13 +231,11 @@ it('never re-offers a resumed chat when the status entry reopens the dialog', as sessions: remaining } }) - await act(async () => - root.render( - - - - - ) + await mount( + <> + + + ) await act(async () => checkbox(1).click()) await act(async () => button('Resume 1 chat').click()) @@ -230,7 +263,7 @@ it('settles the offer for the chats a resume reattached', async () => { sessions: [offered[1]!] } ) - await act(async () => root.render()) + await mount() await act(async () => checkbox(1).click()) await act(async () => button('Resume 1 chat').click()) expect(offerIds()).toEqual(['b']) @@ -255,12 +288,10 @@ it('resumes and continues once when the launch begins opted in', async () => { sessions: [] } ) - await act(async () => - root.render( - - - - ) + await mount( + + + ) await act(async () => useAppStore.getState().updateSettings({ nativeChatResumeWorkOnRestart: false }) @@ -302,10 +333,14 @@ it('reports refused and newly ineligible chats on an opted-in launch', async () sessions: offered } ) - await act(async () => root.render()) + await mount() + // One count, no names: the modal has the list. The host listed no failure, so there is nothing + // the toast may forget — a chat that only dropped out of the answer is still an offer. expect(toast).toHaveBeenCalledWith( - '2 chats could not be continued. Open them to continue manually.' + '2 chats couldn’t be resumed', + expect.objectContaining({ action: expect.objectContaining({ label: 'Show' }) }) ) + expect(vi.mocked(toast).mock.calls.at(-1)?.[1]).not.toHaveProperty('cancel') }) it('dispatches the selected action while a future preference save is still pending', async () => { @@ -320,7 +355,7 @@ it('dispatches the selected action while a future preference save is still pendi sessions: [] } ) - await act(async () => root.render()) + await mount() await act(async () => checkbox(1).click()) await act(async () => checkbox(2).click()) await act(async () => button('Resume 1 chat').click()) @@ -344,14 +379,16 @@ it.each(['pending', 'unknown', 'refused', 'missing'])( sessions: offered } ) - await act(async () => root.render()) + await mount() await act(async () => button('Resume 2 chats').click()) const notices = vi .mocked(toast) .mock.calls.map(([text]) => text) .join(' ') expect(notices).toContain( - outcome === 'pending' || outcome === 'unknown' ? 'unconfirmed' : 'could not be continued' + outcome === 'pending' || outcome === 'unknown' + ? 'Couldn’t confirm 2 chats were resumed' + : '2 chats couldn’t be resumed' ) expect(notices).not.toContain('asked them to continue') expect(rpc).toHaveBeenCalledTimes(2) @@ -364,7 +401,7 @@ it('reports an unreadable resume response as an unconfirmed delivery', async () rpc.mockImplementation(async (_target, method) => method === 'agentSession.restartResumable' ? { sessions: offered } : { sessions: offered } ) - await act(async () => root.render()) + await mount() await act(async () => button('Resume 2 chats').click()) expect(toast).toHaveBeenCalledWith(expect.stringContaining('unconfirmed')) expect(offerIds()).toEqual(['a', 'b']) @@ -378,7 +415,7 @@ it('reports a lost resume response without retrying the action', async () => { } throw new Error('response lost') }) - await act(async () => root.render()) + await mount() await act(async () => button('Resume 2 chats').click()) expect(toast).toHaveBeenCalledWith(expect.stringContaining('unconfirmed')) // A lost action response is followed by a read-only reconciliation, never a retry. @@ -390,7 +427,7 @@ it('reports a lost resume response without retrying the action', async () => { expect(document.querySelector('[role="dialog"]')).toBeNull() }) -it('keeps an unconfirmed delivery visible when another chat was refused', async () => { +it('counts an unconfirmed delivery apart from a refusal in one notice', async () => { rpc.mockImplementation(async (_target, method) => method === 'agentSession.restartResumable' ? { sessions: offered } @@ -402,10 +439,184 @@ it('keeps an unconfirmed delivery visible when another chat was refused', async sessions: offered } ) - await act(async () => root.render()) + await mount() await act(async () => button('Resume 2 chats').click()) - expect(toast).toHaveBeenCalledWith('1 chat could not be continued. Open it to continue manually.') - expect(vi.mocked(toast).mock.calls.at(-1)?.[0]).toBe( - 'Continuation delivery is unconfirmed for 1 chat. Open it to check before sending another message.' + expect(vi.mocked(toast).mock.calls).toEqual([ + [ + '1 chat couldn’t be resumed', + expect.objectContaining({ description: 'Couldn’t confirm 1 other chat was resumed' }) + ] + ]) +}) + +// The toast is gone in seconds; what it can do has to land somewhere durable: the list, or the +// host's records. +it('lets the failure notice open the list or forget the chats it counted', async () => { + rpc.mockImplementation(async (_target, method) => + method === 'agentSession.restartResumable' + ? { sessions: offered } + : method === 'agentSession.restartContinue' + ? // `b` was requested too but dropped out of the answer without the host failing it. + { + continued: [{ sessionId: 'a', outcome: 'refused' }], + sessions: [offered[1]!], + failed: [failure('a')] + } + : { dismissed: 1, sessions: [offered[1]!], failed: [] } ) + await mount() + await act(async () => button('Resume 2 chats').click()) + const options = vi.mocked(toast).mock.calls.at(-1)?.[1] + consumeNativeChatResumeOnRestartDialogRequest() + await act(async () => press(options?.action)) + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + await act(async () => press(options?.cancel)) + expect(rpc.mock.calls.at(-1)?.slice(1)).toEqual([ + 'agentSession.restartResumableDismiss', + { sessionIds: ['a'] } + ]) +}) + +/** Every button in the dialog, in order; row checkboxes are buttons too, so they are left out. */ +function dialogControls(): (string | null)[] { + return [...document.querySelectorAll('[role="dialog"] button:not([role="checkbox"])')].map( + (entry) => entry.textContent?.trim() || entry.getAttribute('aria-label') + ) +} + +// The old toast said "1 chat could not be continued" and vanished. The chat now stays in the same +// dialog — same title, checkboxes and footer — with its row saying what went wrong and what to do. +it('keeps a chat the resume could not carry on in the same dialog, with what to do', async () => { + let remaining: unknown[] = [] + rpc.mockImplementation(async (_target, method) => + method === 'agentSession.restartResumable' + ? { sessions: offered, failed: remaining } + : ((remaining = [failure('b')]), + { + resumed: offered.map(({ sessionId }) => ({ sessionId, outcome: 'resumed' })), + continued: [ + { sessionId: 'a', outcome: 'continued' }, + { sessionId: 'b', outcome: 'refused', reason: 'agent_session_restart_work_superseded' } + ], + sessions: [], + failed: remaining + }) + ) + await mount() + await act(async () => button('Resume 2 chats').click()) + const dialog = document.querySelector('[role="dialog"]') + expect(dialog).not.toBeNull() + // Unchanged chrome: the title, the preference box, and the two footer actions. + expect(dialog?.textContent).toContain('Resume interrupted chats?') + expect(dialog?.textContent).toContain("Don't ask again (resume automatically)") + expect(dialog?.textContent).not.toContain('Dismiss failed') + // The resumed chat left the list as it always did; the failed one is a row with a checkbox. + expect(dialog?.textContent).not.toContain('Prompt a') + expect(document.querySelectorAll('[role="checkbox"]')).toHaveLength(2) + expect(document.querySelector('[aria-label="Prompt b: Couldn’t resume"]')).not.toBeNull() + expect(dialog?.textContent).toContain('To resume:') + expect(dialog?.textContent).toContain('Open the chat and reply.') + expect(dialogControls()).toEqual([ + 'Dismiss "Prompt b" in workspace', + 'Open chat', + 'Dismiss all', + 'Resume 0 chats', + 'Close' + ]) + // A retry cannot fix newer work in the chat, so it is not pre-selected for one. + expect(checkbox(0).getAttribute('data-state')).toBe('unchecked') + + await act(async () => button('Open chat').click()) + expect(activate).toHaveBeenCalledWith({ + structuredSession: { workspaceId: 'workspace', sessionId: 'b' } + }) + // Opening is read-only: the record stays with the host, the dialog just gets out of the way. + expect(rpc.mock.calls.map((call) => call[1])).not.toContain( + 'agentSession.restartResumableDismiss' + ) + expect(document.querySelector('[role="dialog"]')).toBeNull() +}) + +// Selecting a failed row and pressing Resume is the retry; the row's own Retry does the same. +it.each(['footer', 'row'] as const)( + 'retries a failed chat by name from the %s when a retry can succeed', + async (from) => { + let failed = [failure('b', 'agent_session_conflict')] + rpc.mockImplementation(async (_target, method) => + method === 'agentSession.restartResumable' + ? { sessions: [], failed } + : ((failed = []), + { + resumed: [{ sessionId: 'b', outcome: 'resumed' }], + continued: [{ sessionId: 'b', outcome: 'continued' }], + sessions: [], + failed + }) + ) + await mount() + // Old failures never raise the launch dialog by themselves; the status entry does. + expect(document.querySelector('[role="dialog"]')).toBeNull() + await act(async () => requestNativeChatResumeOnRestartDialog()) + expect(document.querySelector('[role="dialog"]')?.textContent).toContain( + 'Close it, then retry.' + ) + // A retry can fix an ownership clash, so the row starts selected. + expect(checkbox(0).getAttribute('data-state')).toBe('checked') + + await act(async () => button(from === 'footer' ? 'Resume 1 chat' : 'Retry').click()) + expect(rpc.mock.calls.at(-1)?.slice(1)).toEqual([ + 'agentSession.restartContinue', + { sessionIds: ['b'] } + ]) + expect(toast).toHaveBeenCalledWith('Resumed 1 chat and asked it to continue') + expect(document.querySelector('[role="dialog"]')).toBeNull() + } +) + +// The user's case: the only row is a failure the host says a retry cannot fix. Ticking it could +// only fail again, so the row's own action is the way on and the box cannot be ticked. +it('keeps a failure the host marks unretryable out of Resume, even after a tick', async () => { + let failed: unknown[] = [failure('b')] + rpc.mockImplementation(async () => ({ sessions: [], failed })) + await mount() + await act(async () => requestNativeChatResumeOnRestartDialog()) + // An older host sends no flag, and the row stays selectable as it always was. + expect(checkbox(0).hasAttribute('disabled')).toBe(false) + await act(async () => checkbox(0).click()) + expect(button('Resume 1 chat').disabled).toBe(false) + + failed = [{ ...failure('b'), retryable: false }] + await act(async () => void (await refreshNativeChatRestartOffer())) + expect(checkbox(0).hasAttribute('disabled')).toBe(true) + expect(button('Resume 0 chats').disabled).toBe(true) + expect(button('Open chat').disabled).toBe(false) +}) + +it('dismisses one failed chat by name, and every record through Dismiss all', async () => { + rpc.mockImplementation(async (_target, method, params: { sessionIds?: string[] } | undefined) => + method === 'agentSession.restartResumable' + ? { sessions: [], failed: [failure('a'), failure('b')] } + : { + dismissed: 1, + sessions: [], + failed: params?.sessionIds + ? [failure('a'), failure('b')].filter( + (entry) => !params.sessionIds?.includes(entry.sessionId) + ) + : [] + } + ) + await mount() + await act(async () => requestNativeChatResumeOnRestartDialog()) + expect(document.querySelectorAll('[role="checkbox"]')).toHaveLength(3) + await act(async () => button('Dismiss "Prompt a" in workspace').click()) + expect(rpc.mock.calls.at(-1)?.slice(1)).toEqual([ + 'agentSession.restartResumableDismiss', + { sessionIds: ['a'] } + ]) + expect(document.querySelector('[role="dialog"]')?.textContent).not.toContain('Prompt a') + expect(document.querySelectorAll('[role="checkbox"]')).toHaveLength(2) + await act(async () => button('Dismiss all').click()) + expect(rpc.mock.calls.at(-1)?.slice(1)).toEqual(['agentSession.restartResumableDismiss', {}]) + expect(document.querySelector('[role="dialog"]')).toBeNull() }) diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx index cdddb5aeea1..01f3bd1de3c 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx @@ -12,7 +12,14 @@ import { } from './ui/dialog' import { useAppStore } from '../store' import { translate } from '@/i18n/i18n' +import { activateAiVaultStructuredSession } from '@/lib/activate-ai-vault-structured-session' import { ResumeOnRestartGroups } from './NativeChatResumeOnRestartGroups' +import { + resumeFailureGuidance, + resumeFailureSelectable, + type ResumeFailureAction +} from './native-chat-resume-failure-guidance' +import type { ResumeCandidate, ResumeFailure } from './native-chat-resume-on-restart-grouping' import { consumeNativeChatResumeOnRestartDialogRequest, getNativeChatResumeOnRestartDialogRequest, @@ -21,6 +28,7 @@ import { import { continueNativeChatRestartOffer, dismissNativeChatRestartOffer, + getNativeChatRestartOffer, useNativeChatRestartOffer } from './native-chat-resume-on-restart-store' @@ -34,15 +42,33 @@ import { * The "don't ask again" box removes the PROMPT, never a safety check — an opted-in launch calls * the same RPC, which re-derives the same predicate and staggers the same way. * + * A chat an earlier resume could not carry on is listed too, as the same row plus what went wrong + * and what to do; selecting it and resuming is a retry, unless the host says a retry cannot run. + * The dialog stays open while any remain, so the outcome is never left to a toast. + * * Closing is a SNOOZE, so looking around before deciding cannot remove the recovery. Dismiss all is * the explicit path that deletes the durable records. */ +/** Pre-selected unless it is a failure a retry cannot fix; resuming that would only fail again. */ +function selectedByDefault(failure: ResumeFailure | undefined): boolean { + if (!failure) { + return true + } + const guidance = resumeFailureGuidance(failure) + return guidance.primary === 'retry' || guidance.secondary === 'retry' +} + export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { const structuredEnabled = useAppStore( (store) => store.settings?.experimentalStructuredNativeChat === true ) - const { candidates, listedAt } = useNativeChatRestartOffer(structuredEnabled) + const { candidates, failed, listedAt } = useNativeChatRestartOffer(structuredEnabled) + const rows = useMemo(() => [...candidates, ...failed], [candidates, failed]) + const failureBySession = useMemo( + () => new Map(failed.map((failure) => [failure.sessionId, failure])), + [failed] + ) // Open is an external one-shot request, never mirrored into local state: the launch load and the // status-bar entry both raise it, and a copy here would go stale against whichever raised it last. const open = useSyncExternalStore( @@ -53,30 +79,29 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { const updateSettings = useAppStore((store) => store.updateSettings) const [dontAskAgain, setDontAskAgain] = useState(false) const [busy, setBusy] = useState(false) - /** Which of the OFFERED chats to leave out. Tracked as EXCLUSIONS rather than a selection because - * the list is the host's and arrives — and shrinks — under an open dialog; a stored selection - * would need seeding from an effect every time it changed. */ - const [excluded, setExcluded] = useState>(() => new Set()) - /** Derived from the host's own list, so an action can never name a chat it did not offer. */ + /** The user's own ticks and unticks, over each row's default. Tracked as OVERRIDES rather than a + * selection because the list is the host's and arrives — and shrinks — under an open dialog; a + * stored selection would need seeding from an effect every time it changed. */ + const [overrides, setOverrides] = useState>(() => new Map()) + /** Derived from the host's own list, so an action can never name a chat it did not list. */ const chosen = useMemo( () => - candidates - .map((candidate) => candidate.sessionId) - .filter((sessionId) => !excluded.has(sessionId)), - [candidates, excluded] + rows + .filter((row) => { + const failure = failureBySession.get(row.sessionId) + // A tick made before the host marked it unretryable must not carry into the action. + return ( + (!failure || resumeFailureSelectable(failure)) && + (overrides.get(row.sessionId) ?? selectedByDefault(failure)) + ) + }) + .map((row) => row.sessionId), + [rows, overrides, failureBySession] ) const selected = useMemo(() => new Set(chosen), [chosen]) const toggleSelected = useCallback((sessionId: string, checked: boolean) => { - setExcluded((current) => { - const next = new Set(current) - if (checked) { - next.delete(sessionId) - } else { - next.add(sessionId) - } - return next - }) + setOverrides((current) => new Map(current).set(sessionId, checked)) }, []) /** Applied on whichever action the user takes, so the box means the same thing every way out. */ @@ -94,7 +119,10 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { await continueNativeChatRestartOffer(sessionIds) } finally { setBusy(false) - consumeNativeChatResumeOnRestartDialogRequest() + // Stays open when a chat did not carry on: its row now says what to do about it. + if (getNativeChatRestartOffer().failed.length === 0) { + consumeNativeChatResumeOnRestartDialogRequest() + } } }, [persistPreference] @@ -114,11 +142,32 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { await dismissNativeChatRestartOffer() }, [persistPreference]) - if (!structuredEnabled || !open || candidates.length === 0) { + const actOnFailure = async (action: ResumeFailureAction, sessionId: string): Promise => { + if (action === 'dismiss') { + await dismissNativeChatRestartOffer([sessionId]) + return + } + if (action === 'retry') { + await resume([sessionId]) + return + } + const failure = failureBySession.get(sessionId) + if (!failure) { + return + } + // Opening is read-only and keeps the record: the user's own send in that chat settles it. The + // dialog gets out of the way of the chat it just opened. + consumeNativeChatResumeOnRestartDialogRequest() + await activateAiVaultStructuredSession({ + structuredSession: { workspaceId: failure.workspaceId, sessionId } + }) + } + + if (!structuredEnabled || !open || rows.length === 0) { return null } - const interruptedByUpdate = candidates.some((candidate) => candidate.trigger === 'update') + const interruptedByUpdate = rows.some((row) => row.trigger === 'update') return ( failureBySession.get(sessionId)} + onFailureAction={(action, sessionId) => void actOnFailure(action, sessionId)} /> diff --git a/src/renderer/src/components/native-chat-restart-action-notifications.test.ts b/src/renderer/src/components/native-chat-restart-action-notifications.test.ts new file mode 100644 index 00000000000..d48d1d2118c --- /dev/null +++ b/src/renderer/src/components/native-chat-restart-action-notifications.test.ts @@ -0,0 +1,77 @@ +import { toast } from 'sonner' +import { beforeEach, expect, it, vi } from 'vitest' +import { announceRestartResults } from './native-chat-restart-action-notifications' + +vi.mock('sonner', () => ({ toast: vi.fn() })) + +const actions = { show: vi.fn(), dismiss: vi.fn() } +const refusedBoth = [ + { sessionId: 'a', outcome: 'refused' as const }, + { sessionId: 'b', outcome: 'refused' as const } +] + +beforeEach(() => vi.mocked(toast).mockClear()) + +// `b` finished on its own, or the user already answered it: the host no longer lists it, so the +// notice must not count a failure the list it opens cannot show. +it('counts only the requested chats the host still lists as failed', () => { + announceRestartResults(['a', 'b'], refusedBoth, [{ sessionId: 'a', outcome: 'refused' }], actions) + expect(vi.mocked(toast).mock.calls.map(([text]) => text)).toEqual(['1 chat couldn’t be resumed']) +}) + +it('says nothing when the host lists none of them as failed', () => { + announceRestartResults(['a', 'b'], refusedBoth, [], actions) + expect(toast).not.toHaveBeenCalled() +}) + +it('counts every chat not carried on when an older host sends no failure list', () => { + announceRestartResults(['a', 'b'], refusedBoth, undefined, actions) + expect(vi.mocked(toast).mock.calls.map(([text]) => text)).toEqual(['2 chats couldn’t be resumed']) +}) + +// The host retires an unconfirmed send once the agent is seen carrying on it; the action must still +// report the chat, and as resumed, not as a failure the list can no longer show. +it('counts an unconfirmed chat the host no longer lists as resumed', () => { + announceRestartResults(['a'], [{ sessionId: 'a', outcome: 'unknown' }], [], actions) + expect(vi.mocked(toast).mock.calls.map(([text]) => text)).toEqual([ + 'Resumed 1 chat and asked it to continue' + ]) +}) + +// Unconfirmed means the agent may well be working; "couldn't be resumed" would invite a second send. +// `b` reattached with no continuation row: only the host's filed outcome says it is unconfirmed. +it('counts a chat the host filed as unconfirmed on its own line, as the list does', () => { + announceRestartResults( + ['a', 'b'], + [{ sessionId: 'a', outcome: 'refused' }], + [ + { sessionId: 'a', outcome: 'refused' }, + { sessionId: 'b', outcome: 'unconfirmed' } + ], + actions + ) + expect(vi.mocked(toast).mock.calls).toEqual([ + [ + '1 chat couldn’t be resumed', + expect.objectContaining({ description: 'Couldn’t confirm 1 other chat was resumed' }) + ] + ]) +}) + +it('leads with the unconfirmed count when nothing was refused', () => { + announceRestartResults( + ['a', 'b'], + [ + { sessionId: 'a', outcome: 'unknown' }, + { sessionId: 'b', outcome: 'pending' } + ], + undefined, + actions + ) + expect(vi.mocked(toast).mock.calls).toEqual([ + [ + 'Couldn’t confirm 2 chats were resumed', + expect.not.objectContaining({ description: expect.anything() }) + ] + ]) +}) diff --git a/src/renderer/src/components/native-chat-restart-action-notifications.ts b/src/renderer/src/components/native-chat-restart-action-notifications.ts index f794fcc5569..b1f09fdc851 100644 --- a/src/renderer/src/components/native-chat-restart-action-notifications.ts +++ b/src/renderer/src/components/native-chat-restart-action-notifications.ts @@ -1,5 +1,6 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' +import type { ResumeFailure } from './native-chat-resume-on-restart-grouping' /** * What Orca tells the user after acting on a restart offer. @@ -46,6 +47,90 @@ export function announceRestartUnconfirmed(count: number): void { ) } +/** What the failure toast can do: open the modal that lists the chats, or forget them. Passed in + * because the offer store owns both and this module must not import it back. */ +export type RestartFailureActions = { + show: () => void + dismiss: (sessionIds: readonly string[]) => void +} + +function refusedCountText(count: number): string { + return count === 1 + ? translate( + 'auto.components.NativeChatResumeOnRestartModal.notContinuedOne', + '1 chat couldn’t be resumed' + ) + : translate( + 'auto.components.NativeChatResumeOnRestartModal.notContinuedMany', + '{{value0}} chats couldn’t be resumed', + { value0: count } + ) +} + +function unconfirmedCountText(count: number): string { + return count === 1 + ? translate( + 'auto.components.NativeChatResumeOnRestartModal.notConfirmedOne', + 'Couldn’t confirm 1 chat was resumed' + ) + : translate( + 'auto.components.NativeChatResumeOnRestartModal.notConfirmedMany', + 'Couldn’t confirm {{value0}} chats were resumed', + { value0: count } + ) +} + +/** Beneath a refused count, so it cannot read as the same chat restated. */ +function otherUnconfirmedCountText(count: number): string { + return count === 1 + ? translate( + 'auto.components.NativeChatResumeOnRestartModal.notConfirmedOtherOne', + 'Couldn’t confirm 1 other chat was resumed' + ) + : translate( + 'auto.components.NativeChatResumeOnRestartModal.notConfirmedOtherMany', + 'Couldn’t confirm {{value0}} other chats were resumed', + { value0: count } + ) +} + +/** The chats an action did not carry on. No names here: the modal has the list, and the count is + * the same shape whether it is one chat or ten. Unconfirmed chats get their own count because the + * agent may well be working; "couldn't be resumed" would invite a duplicate send. Dismiss forgets + * only the chats the host listed as failed — a chat that merely dropped out of the answer may + * still be a live offer. */ +function announceNotContinued( + refused: readonly string[], + unconfirmed: readonly string[], + hostFailed: ReadonlySet, + actions: RestartFailureActions +): void { + const counted = [...refused, ...unconfirmed] + if (counted.length === 0) { + return + } + const dismissable = counted.filter((sessionId) => hostFailed.has(sessionId)) + const title = + refused.length > 0 ? refusedCountText(refused.length) : unconfirmedCountText(unconfirmed.length) + toast(title, { + ...(refused.length > 0 && unconfirmed.length > 0 + ? { description: otherUnconfirmedCountText(unconfirmed.length) } + : {}), + action: { + label: translate('auto.components.NativeChatResumeOnRestartModal.show', 'Show'), + onClick: actions.show + }, + ...(dismissable.length === 0 + ? {} + : { + cancel: { + label: translate('auto.components.NativeChatResumeOnRestartModal.dismiss', 'Dismiss'), + onClick: () => actions.dismiss(dismissable) + } + }) + }) +} + /** A dismissal Orca could not confirm. The offer belongs to the host, so say it may still be there. */ export function announceRestartDismissUnconfirmed(): void { toast( @@ -56,34 +141,48 @@ export function announceRestartDismissUnconfirmed(): void { ) } -export function announceRestartResults( +/** Which of the requested chats the host did not carry on: refused, unconfirmed, or — since + * eligibility can change after listing — omitted from the answer altogether. */ +export function restartChatsNotContinued( requested: readonly string[], results: readonly RestartContinuationOutcome[] -): void { +): string[] { const bySession = new Map(results.map((result) => [result.sessionId, result.outcome])) - let succeeded = 0 - let unconfirmed = 0 - let refused = 0 - for (const sessionId of new Set(requested)) { - const outcome = bySession.get(sessionId) - if (outcome === 'continued') { - succeeded += 1 - } else if (outcome === 'pending' || outcome === 'unknown') { - unconfirmed += 1 - } else { - // Eligibility can change after listing, so an omitted row was not acted on either. - refused += 1 - } - } - announceContinued(succeeded) - if (refused > 0) { - toast( - translate( - 'auto.components.NativeChatResumeOnRestartModal.continueRefused', - '{{value0}} chats could not be continued. Open them to continue manually.', - { value0: refused, count: refused } - ) - ) - } - announceRestartUnconfirmed(unconfirmed) + return [...new Set(requested)].filter((sessionId) => bySession.get(sessionId) !== 'continued') +} + +export function announceRestartResults( + requested: readonly string[], + results: readonly RestartContinuationOutcome[], + /** The host's own failure list after the action; undefined from an older host. */ + hostFailed: readonly Pick[] | undefined, + actions: RestartFailureActions +): void { + const notContinued = restartChatsNotContinued(requested, results) + const failed = new Map(hostFailed?.map((failure) => [failure.sessionId, failure.outcome])) + // A host that lists failures has already dropped chats that moved on by themselves or that the + // user answered; counting those would report a failure nothing on screen can show. + const reported = + hostFailed === undefined + ? notContinued + : notContinued.filter((sessionId) => failed.has(sessionId)) + const outcomes = new Map(results.map((result) => [result.sessionId, result.outcome])) + const sentUnconfirmed = (sessionId: string): boolean => + outcomes.get(sessionId) === 'pending' || outcomes.get(sessionId) === 'unknown' + // An unconfirmed send the host no longer lists was seen carrying on (or answered by the user), so + // it was resumed and asked to continue; left out of both counts, the action would say nothing. + const seenCarryingOn = notContinued.filter( + (sessionId) => !reported.includes(sessionId) && sentUnconfirmed(sessionId) + ) + // The host's filed outcome is what the list shows, so the toast uses it too. + const unconfirmed = (sessionId: string): boolean => + (failed.get(sessionId) ?? (sentUnconfirmed(sessionId) ? 'unconfirmed' : 'refused')) === + 'unconfirmed' + announceContinued(new Set(requested).size - notContinued.length + seenCarryingOn.length) + announceNotContinued( + reported.filter((sessionId) => !unconfirmed(sessionId)), + reported.filter(unconfirmed), + new Set(failed.keys()), + actions + ) } diff --git a/src/renderer/src/components/native-chat-resume-failed-chat-watch.test.ts b/src/renderer/src/components/native-chat-resume-failed-chat-watch.test.ts new file mode 100644 index 00000000000..308e7b878e8 --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-failed-chat-watch.test.ts @@ -0,0 +1,185 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { + AgentSessionStatusEvent, + AgentSessionStatusSummary +} from '../../../shared/agent-session-wire' +import { resetStructuredAgentSessionStatusFeedsForTests } from '@/runtime/structured-agent-session-status-feed' +import { + _resetNativeChatRestartOffer, + continueNativeChatRestartOffer, + dismissNativeChatRestartOffer, + getNativeChatRestartOffer, + refreshNativeChatRestartOffer +} from './native-chat-resume-on-restart-store' + +const mocks = vi.hoisted(() => ({ + rpc: vi.fn(), + subscribeStatus: vi.fn(), + unsubscribe: vi.fn() +})) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: mocks.rpc, + subscribeStructuredAgentSessionStatus: mocks.subscribeStatus +})) +vi.mock('sonner', () => ({ toast: vi.fn() })) + +// After a restart action leaves a chat failed, the user's reply in that chat must retire the status +// bar entry without the user reopening anything, and nothing may run while nothing failed. + +const failure = { + sessionId: 'a', + workspaceId: 'workspace', + agent: 'codex', + trigger: 'update', + latestPrompt: 'Fix it', + recordedAt: 1, + failedAt: 2, + outcome: 'refused', + reason: 'agent_session_restart_work_superseded' +} + +function summary( + status: AgentSessionStatusSummary['status'], + latestPrompt: string, + updatedAt: number +): AgentSessionStatusSummary { + return { + sessionId: 'a', + workspaceId: 'workspace', + agent: 'codex', + status, + latestPrompt, + updatedAt + } +} + +function hostEmit(): (event: AgentSessionStatusEvent) => void { + const call = mocks.subscribeStatus.mock.calls[0] + if (!call) { + throw new Error('status feed not subscribed') + } + return call[1] +} + +function offerReads(): number { + return mocks.rpc.mock.calls.filter(([, method]) => method === 'agentSession.restartResumable') + .length +} + +/** Lists one failed chat, then answers every later read with `later`. */ +async function listFailure(later: { failed: unknown[] } = { failed: [] }): Promise { + mocks.rpc + .mockResolvedValueOnce({ sessions: [], failed: [failure] }) + .mockResolvedValue({ sessions: [], ...later }) + await refreshNativeChatRestartOffer() + await vi.advanceTimersByTimeAsync(0) +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + vi.clearAllMocks() + mocks.subscribeStatus.mockResolvedValue({ unsubscribe: mocks.unsubscribe }) + _resetNativeChatRestartOffer() + resetStructuredAgentSessionStatusFeedsForTests() +}) + +afterEach(() => { + _resetNativeChatRestartOffer() + resetStructuredAgentSessionStatusFeedsForTests() + vi.useRealTimers() +}) + +it('opens no status stream while nothing failed', async () => { + mocks.rpc.mockResolvedValue({ sessions: [], failed: [] }) + await refreshNativeChatRestartOffer() + await vi.advanceTimersByTimeAsync(0) + expect(mocks.subscribeStatus).not.toHaveBeenCalled() +}) + +it('re-reads once when the user replies in a failed chat, then lets the stream go', async () => { + await listFailure() + expect(mocks.subscribeStatus).toHaveBeenCalledOnce() + const later = Date.now() + 10_000 + hostEmit()({ type: 'status', session: summary('working', 'Carry on please', later) }) + hostEmit()({ type: 'status', session: summary('idle', 'Carry on please', later + 1) }) + expect(offerReads()).toBe(1) + + await vi.advanceTimersByTimeAsync(500) + + expect(offerReads()).toBe(2) + expect(getNativeChatRestartOffer().failed).toEqual([]) + expect(mocks.unsubscribe).toHaveBeenCalledOnce() +}) + +it('does not re-read for an agent streaming in a failed chat or for news older than the list', async () => { + await listFailure({ failed: [failure] }) + const later = Date.now() + 10_000 + // Already reflected in the listing the store holds. + hostEmit()({ type: 'snapshot', sessions: [summary('working', 'Replayed note', 1)] }) + hostEmit()({ type: 'status', session: summary('working', 'Replayed note', later) }) + await vi.advanceTimersByTimeAsync(500) + expect(offerReads()).toBe(1) + + hostEmit()({ type: 'status', session: summary('idle', 'Replayed note', later + 1) }) + await vi.advanceTimersByTimeAsync(500) + // Still failed, so the watch stays for the user's own reply. + expect(offerReads()).toBe(2) + expect(mocks.unsubscribe).not.toHaveBeenCalled() +}) + +// Host and renderer stamp time separately, and the host can answer a list just before a reply is +// delivered here: a change to a chat already seen is news whatever its timestamp says. +it('re-reads for a known failed chat that changes even when its stamp predates the list', async () => { + await listFailure() + hostEmit()({ type: 'snapshot', sessions: [summary('idle', 'Fix it', 1)] }) + hostEmit()({ type: 'status', session: summary('working', 'Carry on please', 2) }) + await vi.advanceTimersByTimeAsync(500) + expect(offerReads()).toBe(2) + expect(getNativeChatRestartOffer().failed).toEqual([]) +}) + +it('never lets a re-read that was already in flight bring back a dismissed failure', async () => { + await listFailure({ failed: [failure] }) + const stale = Promise.withResolvers() + mocks.rpc.mockImplementation((_target: unknown, method: string) => + method === 'agentSession.restartResumable' + ? stale.promise + : Promise.resolve({ sessions: [], failed: [] }) + ) + hostEmit()({ type: 'status', session: summary('working', 'Carry on please', Date.now() + 1) }) + await vi.advanceTimersByTimeAsync(500) + expect(offerReads()).toBe(2) + + await dismissNativeChatRestartOffer(['a']) + expect(getNativeChatRestartOffer().failed).toEqual([]) + stale.resolve({ sessions: [], failed: [failure] }) + await vi.advanceTimersByTimeAsync(0) + + expect(getNativeChatRestartOffer().failed).toEqual([]) +}) + +// A retry changes the chat it acts on; its own answer follows, so a read mid-action would only +// flash a half-finished list. +it('waits for a resume in flight instead of re-reading under it', async () => { + await listFailure({ failed: [failure] }) + const acting = Promise.withResolvers() + mocks.rpc.mockImplementation((_target: unknown, method: string) => + method === 'agentSession.restartContinue' + ? acting.promise + : Promise.resolve({ sessions: [], failed: [failure] }) + ) + const retry = continueNativeChatRestartOffer(['a']) + hostEmit()({ type: 'status', session: summary('working', 'Carry on please', Date.now() + 1) }) + await vi.advanceTimersByTimeAsync(500) + expect(offerReads()).toBe(1) + + acting.resolve({ + sessions: [], + failed: [], + continued: [{ sessionId: 'a', outcome: 'continued' }] + }) + await retry + expect(getNativeChatRestartOffer().failed).toEqual([]) +}) diff --git a/src/renderer/src/components/native-chat-resume-failure-guidance.test.ts b/src/renderer/src/components/native-chat-resume-failure-guidance.test.ts new file mode 100644 index 00000000000..b408e481ce1 --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-failure-guidance.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { resumeFailureGuidance } from './native-chat-resume-failure-guidance' + +// Retry is offered as the primary action only where a second attempt can succeed. A refusal that +// stands sends the user to the chat, the only place it can be continued. +describe('resumeFailureGuidance', () => { + it.each([ + ['agent_session_restart_work_superseded', 'open', null], + ['agent_session_dispatch_rejected', 'open', null], + ['agent_session_conflict', 'retry', 'open'], + ['agent_session_ownership_unknown', 'retry', 'open'], + ['execution_owner_reconciling', 'retry', 'open'], + ['agent_session_not_attached', 'retry', 'open'], + ['agent_session_send_failed', 'retry', 'open'], + ['structured_agent_session_unsupported', 'open', 'dismiss'], + ['agent_session_identity_required', 'open', 'dismiss'], + ['something_this_build_has_never_seen', 'open', 'retry'] + ] as const)('maps %s to %s / %s', (reason, primary, secondary) => { + const guidance = resumeFailureGuidance({ outcome: 'refused', reason }) + expect(guidance).toMatchObject({ primary, secondary }) + expect(guidance.text.length).toBeGreaterThan(0) + }) + + // The provider's own refusal text lands on the fallback, and the chat then already holds the + // refused continuation, so the host reports a retry would not run. + it.each(['codex said no', 'agent_session_conflict'])( + 'offers no retry for %s once the host says a retry would not run', + (reason) => { + expect(resumeFailureGuidance({ outcome: 'refused', reason, retryable: false })).toMatchObject( + { primary: 'open', secondary: 'dismiss' } + ) + expect(resumeFailureGuidance({ outcome: 'refused', reason, retryable: true })).toMatchObject( + resumeFailureGuidance({ outcome: 'refused', reason }) + ) + } + ) + + it('never offers a retry for a delivery nobody could confirm', () => { + expect( + resumeFailureGuidance({ outcome: 'unconfirmed', reason: 'agent_session_conflict' }) + ).toMatchObject({ primary: 'open', secondary: null }) + }) +}) diff --git a/src/renderer/src/components/native-chat-resume-failure-guidance.ts b/src/renderer/src/components/native-chat-resume-failure-guidance.ts new file mode 100644 index 00000000000..e348ca34b4f --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-failure-guidance.ts @@ -0,0 +1,128 @@ +import { translate } from '@/i18n/i18n' +import type { ResumeFailure } from './native-chat-resume-on-restart-grouping' + +/** + * What the user can do about a chat Orca could not resume, decided from the host's reason. + * + * One sentence saying what to do and why, and the button that does it. Retry is the primary action + * only where a second attempt can actually succeed — an ownership clash or a reconnect that never + * happened. A refusal that stands (newer work in the chat, the provider saying no) sends the user to + * the chat itself, because that is the only place it can be continued. + */ + +export type ResumeFailureAction = 'open' | 'retry' | 'dismiss' + +export type ResumeFailureGuidance = { + text: string + primary: ResumeFailureAction + secondary: ResumeFailureAction | null +} + +const RETRYABLE_OWNERSHIP = new Set([ + 'agent_session_ownership_unknown', + 'execution_owner_reconciling' +]) +const RETRYABLE_DELIVERY = new Set(['agent_session_not_attached', 'agent_session_send_failed']) +const NOT_RESUMABLE = new Set([ + 'structured_agent_session_unsupported', + 'agent_session_identity_required' +]) + +/** Whether Resume may name this chat at all. The host already knows a retry would not run; an + * older host omits the flag and the row stays selectable. */ +export function resumeFailureSelectable(failure: Pick): boolean { + return failure.retryable !== false +} + +export function resumeFailureGuidance( + failure: Pick +): ResumeFailureGuidance { + const guidance = reasonGuidance(failure) + // The host already knows a retry would not run, whatever the reason suggests. + return !resumeFailureSelectable(failure) && + (guidance.primary === 'retry' || guidance.secondary === 'retry') + ? { text: manualContinuationText(), primary: 'open', secondary: 'dismiss' } + : guidance +} + +function manualContinuationText(): string { + return translate( + 'auto.components.NativeChatResumeFailureGuidance.fallback', + 'Orca couldn’t resume this chat. Open it to continue manually.' + ) +} + +function reasonGuidance(failure: Pick): ResumeFailureGuidance { + if (failure.outcome === 'unconfirmed') { + return { + text: translate( + 'auto.components.NativeChatResumeFailureGuidance.unconfirmed', + 'Orca couldn’t confirm the “continue” message was delivered. Open the chat and check before sending another.' + ), + primary: 'open', + secondary: null + } + } + const reason = failure.reason + if (reason === 'agent_session_restart_work_superseded') { + return { + text: translate( + 'auto.components.NativeChatResumeFailureGuidance.superseded', + 'Open the chat and reply. New work arrived in it right after the restart, so Orca didn’t send its “continue” message.' + ), + primary: 'open', + secondary: null + } + } + if (reason === 'agent_session_conflict') { + return { + text: translate( + 'auto.components.NativeChatResumeFailureGuidance.conflict', + 'Another Orca window or terminal still owns this session. Close it, then retry.' + ), + primary: 'retry', + secondary: 'open' + } + } + if (RETRYABLE_OWNERSHIP.has(reason)) { + return { + text: translate( + 'auto.components.NativeChatResumeFailureGuidance.ownershipUnknown', + 'Orca is still working out which process owns this session. Wait a moment, then retry.' + ), + primary: 'retry', + secondary: 'open' + } + } + if (RETRYABLE_DELIVERY.has(reason)) { + return { + text: translate( + 'auto.components.NativeChatResumeFailureGuidance.notAttached', + 'The chat didn’t reconnect. Retry, or open it to start a fresh turn.' + ), + primary: 'retry', + secondary: 'open' + } + } + if (reason === 'agent_session_dispatch_rejected') { + return { + text: translate( + 'auto.components.NativeChatResumeFailureGuidance.dispatchRejected', + 'The agent refused the “continue” message. Open the chat and send one yourself.' + ), + primary: 'open', + secondary: null + } + } + if (NOT_RESUMABLE.has(reason)) { + return { + text: translate( + 'auto.components.NativeChatResumeFailureGuidance.unsupported', + 'This chat can’t be resumed by Orca. Open it to see where it stopped.' + ), + primary: 'open', + secondary: 'dismiss' + } + } + return { text: manualContinuationText(), primary: 'open', secondary: 'retry' } +} diff --git a/src/renderer/src/components/native-chat-resume-on-restart-grouping.ts b/src/renderer/src/components/native-chat-resume-on-restart-grouping.ts index a503f440d92..f2c06b19551 100644 --- a/src/renderer/src/components/native-chat-resume-on-restart-grouping.ts +++ b/src/renderer/src/components/native-chat-resume-on-restart-grouping.ts @@ -25,6 +25,17 @@ export type ResumeCandidate = { model?: string } +/** An offer that was acted on and did not end with the agent carrying on. The host keeps it until + * the user opens the chat and sends, retries successfully, dismisses it, or it expires. */ +export type ResumeFailure = ResumeCandidate & { + failedAt: number + outcome: 'refused' | 'unconfirmed' + /** The host's or provider's refusal code, verbatim. */ + reason: string + /** Whether a retry would run at all; an older host omits it and the reason decides alone. */ + retryable?: boolean +} + export type ResumeWorkspaceGroup = { workspaceId: string candidates: ResumeCandidate[] diff --git a/src/renderer/src/components/native-chat-resume-on-restart-store.ts b/src/renderer/src/components/native-chat-resume-on-restart-store.ts index f1416b4900a..a3012495a3c 100644 --- a/src/renderer/src/components/native-chat-resume-on-restart-store.ts +++ b/src/renderer/src/components/native-chat-resume-on-restart-store.ts @@ -1,5 +1,10 @@ import { useEffect, useSyncExternalStore } from 'react' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { + getStructuredAgentSessionStatusFeed, + type StructuredAgentSessionStatusFeedOwner +} from '@/runtime/structured-agent-session-status-feed' +import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' import { useAppStore } from '../store' import { announceRestartDismissUnconfirmed, @@ -7,7 +12,11 @@ import { announceRestartUnconfirmed, type RestartContinuationOutcome } from './native-chat-restart-action-notifications' -import { allResumeSessionIds, type ResumeCandidate } from './native-chat-resume-on-restart-grouping' +import { + allResumeSessionIds, + type ResumeCandidate, + type ResumeFailure +} from './native-chat-resume-on-restart-grouping' import { requestNativeChatResumeOnRestartDialog } from './native-chat-resume-on-restart-dialog' /** @@ -27,14 +36,20 @@ const LOCAL = { kind: 'local' } as const export type NativeChatRestartOffer = Readonly<{ candidates: readonly ResumeCandidate[] + /** Acted-on offers whose agent did not carry on, as the host still records them. */ + failed: readonly ResumeFailure[] /** Stamped when the list arrived. Row ages read against this rather than a render-time * `Date.now()`, so they stay stable across re-renders and the render stays pure. */ listedAt: number }> -const EMPTY: NativeChatRestartOffer = { candidates: [], listedAt: 0 } +const EMPTY: NativeChatRestartOffer = { candidates: [], failed: [], listedAt: 0 } let offer: NativeChatRestartOffer = EMPTY let launch: Promise | undefined +/** Continue and dismiss calls, begun and settled. Each ends by publishing the host's answer, which + * a re-read that raced it must neither pre-empt nor undo. */ +let actionsBegun = 0 +let actionsSettled = 0 const listeners = new Set<() => void>() const LAUNCH_READ_RETRY_DELAYS_MS = [100, 250, 500] as const @@ -43,11 +58,106 @@ const LAUNCH_READ_RETRY_DELAYS_MS = [100, 250, 500] as const * actually moves the offer. */ function publish(next: NativeChatRestartOffer): void { offer = next + syncFailedChatWatch() for (const listener of listeners) { listener() } } +/** + * Re-reads the host once a failed chat shows new activity, so a reply the user sent there retires + * its entry here too. The host stays the judge; this only asks again. + * + * Held only while something failed. Keyed on status and prompt rather than every summary, so an + * agent streaming in a failed chat costs one re-read, not one per tool call. + */ +const FAILED_CHAT_REFRESH_DELAY_MS = 500 +let failedChatWatch: { + feed: StructuredAgentSessionStatusFeedOwner + seen: Map + release: () => void +} | null = null +let failedChatRefresh: ReturnType | null = null + +function failedChatActivityKey(summary: AgentSessionStatusSummary): string { + return `${summary.status ?? ''}\u0000${summary.latestPrompt}` +} + +function syncFailedChatWatch(): void { + const failedIds = new Set(offer.failed.map((failure) => failure.sessionId)) + if (failedIds.size === 0) { + releaseFailedChatWatch() + return + } + if (!failedChatWatch) { + const feed = getStructuredAgentSessionStatusFeed(LOCAL) + const unsubscribe = feed.subscribe(noticeFailedChatActivity) + const deactivate = feed.activate() + failedChatWatch = { + feed, + seen: new Map(), + release: () => { + unsubscribe() + deactivate() + } + } + } + const { feed, seen } = failedChatWatch + for (const sessionId of seen.keys()) { + if (!failedIds.has(sessionId)) { + seen.delete(sessionId) + } + } + // What the feed already holds is what this listing answered. + for (const sessionId of failedIds) { + const summary = feed.getSnapshot().get(sessionId) + if (summary && !seen.has(sessionId)) { + seen.set(sessionId, failedChatActivityKey(summary)) + } + } +} + +function noticeFailedChatActivity(): void { + if (!failedChatWatch) { + return + } + const { feed, seen } = failedChatWatch + const snapshot = feed.getSnapshot() + let changed = false + for (const failure of offer.failed) { + const summary = snapshot.get(failure.sessionId) + if (!summary) { + continue + } + const key = failedChatActivityKey(summary) + const previous = seen.get(failure.sessionId) + if (previous !== key) { + seen.set(failure.sessionId, key) + // A first sighting is news only if newer than the list; a change to a known chat always is, + // since the host may have answered the list just before the change was delivered here. + changed ||= previous !== undefined || summary.updatedAt > offer.listedAt + } + } + if (changed && failedChatRefresh === null) { + failedChatRefresh = setTimeout(() => { + failedChatRefresh = null + if (actionsBegun === actionsSettled) { + const issued = actionsBegun + void readNativeChatRestartOffer(() => actionsBegun === issued) + } + }, FAILED_CHAT_REFRESH_DELAY_MS) + } +} + +function releaseFailedChatWatch(): void { + if (failedChatRefresh !== null) { + clearTimeout(failedChatRefresh) + failedChatRefresh = null + } + failedChatWatch?.release() + failedChatWatch = null +} + export function getNativeChatRestartOffer(): NativeChatRestartOffer { return offer } @@ -66,31 +176,59 @@ function subscribe(listener: () => void): () => void { */ type HostOfferRead = { candidates: readonly ResumeCandidate[] + failed: readonly ResumeFailure[] available: boolean } -async function readNativeChatRestartOffer(): Promise { +/** The host's answer as this side understands it. `failed` is optional on the wire: an older host + * never sends it, and its absence means nothing to show, not an invalid answer. */ +type HostOfferPayload = { sessions?: unknown; failed?: unknown } + +function failedFrom(payload: HostOfferPayload): ResumeFailure[] { + // SAFETY: the host is the single writer of this shape; a malformed row is a host bug, not input. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: see above. + return Array.isArray(payload.failed) ? (payload.failed as ResumeFailure[]) : [] +} + +async function readNativeChatRestartOffer(current = () => true): Promise { try { - const offered = await callStructuredAgentSession<{ sessions: ResumeCandidate[] }>( + const offered = await callStructuredAgentSession( LOCAL, 'agentSession.restartResumable' ) if (!Array.isArray(offered.sessions)) { throw new Error('agent_session_restart_offer_invalid') } - publish({ candidates: offered.sessions, listedAt: Date.now() }) - return { candidates: offered.sessions, available: true } + const failed = failedFrom(offered) + if (current()) { + publish({ candidates: offered.sessions, failed, listedAt: Date.now() }) + } + return { candidates: offered.sessions, failed, available: true } } catch { // A failed read is not an answer. Hide the last snapshot so a modal can never present a // candidate the host has not confirmed; the durable record remains and a later refresh can // restore it. - publish({ candidates: [], listedAt: Date.now() }) - return { candidates: [], available: false } + if (current()) { + publish({ ...EMPTY, listedAt: Date.now() }) + } + return { candidates: [], failed: [], available: false } } } -export async function refreshNativeChatRestartOffer(): Promise { - return (await readNativeChatRestartOffer()).candidates +export async function refreshNativeChatRestartOffer(): Promise< + Pick +> { + const read = await readNativeChatRestartOffer() + return { candidates: read.candidates, failed: read.failed } +} + +/** What the failure toast can do. The dialog request is external state the toast may raise after + * the dialog that started the action has closed. */ +const failureToastActions = { + show: () => requestNativeChatResumeOnRestartDialog(), + dismiss: (sessionIds: readonly string[]) => { + void dismissNativeChatRestartOffer([...sessionIds]) + } } /** @@ -109,22 +247,32 @@ export async function continueNativeChatRestartOffer( sessionIds: readonly string[] | undefined, reported: readonly string[] = sessionIds ?? [] ): Promise { + actionsBegun += 1 try { - const result = await callStructuredAgentSession<{ - /** Which chats the host reattached. */ - resumed?: { sessionId: string }[] - continued: RestartContinuationOutcome[] - sessions?: ResumeCandidate[] - }>(LOCAL, 'agentSession.restartContinue', sessionIds ? { sessionIds } : {}) - announceRestartResults(reported, result.continued) + const result = await callStructuredAgentSession< + HostOfferPayload & { + /** Which chats the host reattached. */ + resumed?: { sessionId: string }[] + continued: RestartContinuationOutcome[] + } + >(LOCAL, 'agentSession.restartContinue', sessionIds ? { sessionIds } : {}) + const failed = failedFrom(result) + announceRestartResults( + reported, + result.continued, + Array.isArray(result.failed) ? failed : undefined, + failureToastActions + ) if (Array.isArray(result.sessions)) { - publish({ candidates: result.sessions, listedAt: Date.now() }) + publish({ candidates: result.sessions, failed, listedAt: Date.now() }) } else { await refreshNativeChatRestartOffer() } } catch { await refreshNativeChatRestartOffer() announceRestartUnconfirmed(reported.length) + } finally { + actionsSettled += 1 } } @@ -134,21 +282,26 @@ export async function continueNativeChatRestartOffer( * A failed write or unreachable host leaves the durable record untouched; a later read can restore * the offer after the host is available again. */ -export async function dismissNativeChatRestartOffer(): Promise { +export async function dismissNativeChatRestartOffer(sessionIds?: readonly string[]): Promise { + actionsBegun += 1 try { - const result = await callStructuredAgentSession<{ sessions?: ResumeCandidate[] }>( + const result = await callStructuredAgentSession( LOCAL, 'agentSession.restartResumableDismiss', - {} + // Named only for rows the host itself listed as failures, which an older host never does, + // so it is never asked to understand the key. + sessionIds ? { sessionIds: [...sessionIds] } : {} ) if (Array.isArray(result.sessions)) { - publish({ candidates: result.sessions, listedAt: Date.now() }) + publish({ candidates: result.sessions, failed: failedFrom(result), listedAt: Date.now() }) } else { await refreshNativeChatRestartOffer() } } catch { await refreshNativeChatRestartOffer() announceRestartDismissUnconfirmed() + } finally { + actionsSettled += 1 } } @@ -176,6 +329,7 @@ async function loadLaunchOffer(): Promise { read = await readNativeChatRestartOffer() } const offered = read.candidates + // Failures left from an earlier launch are the status bar's to show; only a fresh offer asks. if (offered.length === 0) { return } @@ -204,7 +358,10 @@ export function useNativeChatRestartOffer(enabled: boolean): NativeChatRestartOf /** @internal - tests need a clean module between cases. */ export function _resetNativeChatRestartOffer(): void { + releaseFailedChatWatch() offer = EMPTY + actionsBegun = 0 + actionsSettled = 0 launch = undefined listeners.clear() } diff --git a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx index 4c034a2f0d6..c5a260ed201 100644 --- a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx +++ b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx @@ -15,7 +15,9 @@ import { NativeChatResumeStatusSegment } from './NativeChatResumeStatusSegment' const rpc = vi.hoisted(() => vi.fn()) vi.mock('@/runtime/structured-agent-session-client', () => ({ - callStructuredAgentSession: rpc + callStructuredAgentSession: rpc, + // A failed row opens the status feed; these cases never drive it. + subscribeStructuredAgentSessionStatus: () => new Promise(() => {}) })) vi.mock('sonner', () => ({ toast: vi.fn() })) @@ -85,6 +87,54 @@ describe('NativeChatResumeStatusSegment', () => { expect(getNativeChatResumeOnRestartDialogRequest()).toBe(true) }) + // The offer is spent once acted on, so without this entry a failed resume would leave the bar + // empty seconds after the toast went. The two are different facts and stay two entries. + it('keeps a failed resume as its own entry beside any remaining offer', async () => { + const failed = { + ...candidates[0]!, + failedAt: 60_000, + outcome: 'refused', + reason: 'agent_session_restart_work_superseded' + } + rpc.mockResolvedValue({ sessions: candidates.slice(1), failed: [failed] }) + await mount() + + expect(screen.getByText('1 chat to resume')).toBeTruthy() + const entry = screen.getByRole('button', { + name: '1 chat failed to resume. Click for details.' + }) + expect(entry.textContent).toBe('1 chat failed to resume') + + rpc.mockResolvedValue({ sessions: [], failed: [failed] }) + await act(async () => entry.click()) + expect(getNativeChatResumeOnRestartDialogRequest()).toBe(true) + // With the offer gone, only the failure entry is left — and it stays. + expect(screen.queryByText('1 chat to resume')).toBeNull() + expect(screen.getByText('1 chat failed to resume')).toBeTruthy() + }) + + // The agent may be working on an unconfirmed one, so the entry must not call it failed — the + // dialog says "couldn't confirm" for that row, and "failed" would invite a second "continue". + it('does not call an unconfirmed resume failed', async () => { + const failure = (sessionId: 'a' | 'b', outcome: 'refused' | 'unconfirmed') => ({ + ...candidates.find((entry) => entry.sessionId === sessionId)!, + failedAt: 60_000, + outcome, + reason: outcome === 'refused' ? 'agent_session_restart_work_superseded' : 'pending' + }) + rpc.mockResolvedValue({ + sessions: [], + failed: [failure('a', 'refused'), failure('b', 'unconfirmed')] + }) + await mount() + + expect( + screen.getByRole('button', { name: '2 chats to check after resuming. Click for details.' }) + .textContent + ).toBe('2 chats to check') + expect(screen.queryByText(/failed to resume/)).toBeNull() + }) + it('names a single chat in the singular', async () => { rpc.mockResolvedValue({ sessions: candidates.slice(0, 1) }) await mount() diff --git a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx index 65969411f5c..0bd87f1d128 100644 --- a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx +++ b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx @@ -1,4 +1,4 @@ -import { RotateCcw } from 'lucide-react' +import { AlertCircle, RotateCcw } from 'lucide-react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' @@ -10,17 +10,118 @@ import { // Why: closing the resume dialog is a snooze, not a decline — the host keeps the offer. This is // then the only surface left carrying it, so it is always rendered rather than gated by -// `statusBarItems`. +// `statusBarItems`. A chat the resume could not carry on is kept the same way: the toast that +// reported it is gone in seconds, and this entry is what still names it. -/** Re-reads the host before opening so the dialog always reflects the current durable offer. Opening - * the chat itself is read-only and does not retire the offer. */ +/** Re-reads the host before opening so the dialog always reflects the current durable records. + * Opening the chat itself is read-only and does not retire the offer. */ async function reopenOffer(): Promise { - const offered = await refreshNativeChatRestartOffer() - if (offered.length > 0) { + const { candidates, failed } = await refreshNativeChatRestartOffer() + if (candidates.length > 0 || failed.length > 0) { requestNativeChatResumeOnRestartDialog() } } +function Segment({ + icon, + label, + ariaLabel, + tooltip, + iconOnly, + count +}: { + icon: React.ReactNode + label: string + ariaLabel: string + tooltip: string + iconOnly: boolean + count: number +}): React.JSX.Element { + return ( + + + + + + {tooltip} + + + ) +} + +type SegmentText = { label: string; ariaLabel: string; tooltip: string } + +function failedText(count: number): SegmentText { + return { + label: + count === 1 + ? translate( + 'auto.components.status.bar.NativeChatResumeStatusSegment.failedLabelOne', + '1 chat failed to resume' + ) + : translate( + 'auto.components.status.bar.NativeChatResumeStatusSegment.failedLabel', + '{{value0}} chats failed to resume', + { value0: count } + ), + ariaLabel: + count === 1 + ? translate( + 'auto.components.status.bar.NativeChatResumeStatusSegment.failedAriaOne', + '1 chat failed to resume. Click for details.' + ) + : translate( + 'auto.components.status.bar.NativeChatResumeStatusSegment.failedAria', + '{{value0}} chats failed to resume. Click for details.', + { value0: count } + ), + tooltip: translate( + 'auto.components.status.bar.NativeChatResumeStatusSegment.failedTooltip', + 'Chats Orca could not resume after the restart. Click for details.' + ) + } +} + +/** True of a refused chat and an unconfirmed one alike, for a list holding either. */ +function checkText(count: number): SegmentText { + return { + label: + count === 1 + ? translate( + 'auto.components.status.bar.NativeChatResumeStatusSegment.checkLabelOne', + '1 chat to check' + ) + : translate( + 'auto.components.status.bar.NativeChatResumeStatusSegment.checkLabel', + '{{value0}} chats to check', + { value0: count } + ), + ariaLabel: + count === 1 + ? translate( + 'auto.components.status.bar.NativeChatResumeStatusSegment.checkAriaOne', + '1 chat to check after resuming. Click for details.' + ) + : translate( + 'auto.components.status.bar.NativeChatResumeStatusSegment.checkAria', + '{{value0}} chats to check after resuming. Click for details.', + { value0: count } + ), + tooltip: translate( + 'auto.components.status.bar.NativeChatResumeStatusSegment.checkTooltip', + 'Chats Orca couldn’t resume, or couldn’t confirm it resumed, after the restart. Click for details.' + ) + } +} + export function NativeChatResumeStatusSegment({ iconOnly }: { @@ -29,32 +130,36 @@ export function NativeChatResumeStatusSegment({ const structuredEnabled = useAppStore( (store) => store.settings?.experimentalStructuredNativeChat === true ) - const { candidates } = useNativeChatRestartOffer(structuredEnabled) - if (!structuredEnabled || candidates.length === 0) { + const { candidates, failed } = useNativeChatRestartOffer(structuredEnabled) + if (!structuredEnabled || (candidates.length === 0 && failed.length === 0)) { return null } - const count = candidates.length - const label = - count === 1 - ? translate( - 'auto.components.status.bar.NativeChatResumeStatusSegment.labelOne', - '1 chat to resume' - ) - : translate( - 'auto.components.status.bar.NativeChatResumeStatusSegment.label', - '{{value0}} chats to resume', - { value0: count } - ) + const pending = candidates.length + const failures = failed.length + // An unconfirmed chat may be working, so "failed" would invite a duplicate "continue". + const unconfirmed = failed.some((failure) => failure.outcome === 'unconfirmed') return ( - - - - - - {translate( - 'auto.components.status.bar.NativeChatResumeStatusSegment.tooltip', - 'Open interrupted chats available to resume' - )} - - + tooltip={translate( + 'auto.components.status.bar.NativeChatResumeStatusSegment.tooltip', + 'Open interrupted chats available to resume' + )} + /> + )} + {failures > 0 && ( + // A different fact from the offer — the outcome of acting on it — so a second entry, not a + // merged count. Same yellow the skill-update segment uses for its own failed state. + } + {...(unconfirmed ? checkText(failures) : failedText(failures))} + /> + )} + ) } diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 0a54d77125d..b5e8431c573 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -73,8 +73,6 @@ "f5a6b38a14": "sheet" }, "NativeChatResumeOnRestartModal": { - "continueRefused_one": "{{value0}} chat could not be continued. Open it to continue manually.", - "continueRefused_other": "{{value0}} chats could not be continued. Open them to continue manually.", "continueUnconfirmed_one": "Continuation delivery is unconfirmed for {{value0}} chat. Open it to check before sending another message.", "continueUnconfirmed_other": "Continuation delivery is unconfirmed for {{value0}} chats. Open them to check before sending another message." }, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index fa8b0761f40..2ddea555dab 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1970,11 +1970,16 @@ "continueUnconfirmed": "Continuation delivery is unconfirmed for {{value0}} chats. Open them to check before sending another message.", "continueUnconfirmed_one": "Continuation delivery is unconfirmed for {{value0}} chat. Open it to check before sending another message.", "continueUnconfirmed_other": "Continuation delivery is unconfirmed for {{value0}} chats. Open them to check before sending another message.", - "continueRefused": "{{value0}} chats could not be continued. Open them to continue manually.", - "continueRefused_one": "{{value0}} chat could not be continued. Open it to continue manually.", - "continueRefused_other": "{{value0}} chats could not be continued. Open them to continue manually.", "dismissAll": "Dismiss all", - "dismissUnconfirmed": "Dismissing the resume offer was not confirmed — it may still be in the status bar." + "dismissUnconfirmed": "Dismissing the resume offer was not confirmed — it may still be in the status bar.", + "notContinuedOne": "1 chat couldn’t be resumed", + "notContinuedMany": "{{value0}} chats couldn’t be resumed", + "notConfirmedOne": "Couldn’t confirm 1 chat was resumed", + "notConfirmedMany": "Couldn’t confirm {{value0}} chats were resumed", + "notConfirmedOtherOne": "Couldn’t confirm 1 other chat was resumed", + "notConfirmedOtherMany": "Couldn’t confirm {{value0}} other chats were resumed", + "show": "Show", + "dismiss": "Dismiss" }, "StarNagCard": { "92b0f9d921": "is authenticated and try again.", @@ -4075,7 +4080,17 @@ "label": "{{value0}} chats to resume", "ariaLabelOne": "1 chat available to resume", "ariaLabel": "{{value0}} chats available to resume", - "tooltip": "Open interrupted chats available to resume" + "tooltip": "Open interrupted chats available to resume", + "failedLabelOne": "1 chat failed to resume", + "failedLabel": "{{value0}} chats failed to resume", + "failedAriaOne": "1 chat failed to resume. Click for details.", + "failedAria": "{{value0}} chats failed to resume. Click for details.", + "failedTooltip": "Chats Orca could not resume after the restart. Click for details.", + "checkLabelOne": "1 chat to check", + "checkLabel": "{{value0}} chats to check", + "checkAriaOne": "1 chat to check after resuming. Click for details.", + "checkAria": "{{value0}} chats to check after resuming. Click for details.", + "checkTooltip": "Chats Orca couldn’t resume, or couldn’t confirm it resumed, after the restart. Click for details." } } }, @@ -17221,6 +17236,26 @@ "9a4c6b2d7e": "Skill sharing", "3d8e5f1b9c": "Share skills behind an unlisted link and install them on any machine you use.", "c5b3e8a17d": "Sign in to Orca" + }, + "NativeChatResumeOutcomeRow": { + "openChat": "Open chat", + "retry": "Retry", + "dismiss": "Dismiss", + "unconfirmed": "Couldn’t confirm the chat was resumed", + "failed": "Couldn’t resume", + "statusFor": "{{value0}}: {{value1}}", + "dismissChat": "Dismiss \"{{value0}}\" in {{value1}}", + "toResume": "To resume:" + }, + "NativeChatResumeFailureGuidance": { + "unconfirmed": "Orca couldn’t confirm the “continue” message was delivered. Open the chat and check before sending another.", + "superseded": "Open the chat and reply. New work arrived in it right after the restart, so Orca didn’t send its “continue” message.", + "conflict": "Another Orca window or terminal still owns this session. Close it, then retry.", + "ownershipUnknown": "Orca is still working out which process owns this session. Wait a moment, then retry.", + "notAttached": "The chat didn’t reconnect. Retry, or open it to start a fresh turn.", + "dispatchRejected": "The agent refused the “continue” message. Open the chat and send one yourself.", + "unsupported": "This chat can’t be resumed by Orca. Open it to see where it stopped.", + "fallback": "Orca couldn’t resume this chat. Open it to continue manually." } }, "i18n": { diff --git a/src/shared/agent-session-restart-continuation.ts b/src/shared/agent-session-restart-continuation.ts index 973caa10de7..6ed816561c6 100644 --- a/src/shared/agent-session-restart-continuation.ts +++ b/src/shared/agent-session-restart-continuation.ts @@ -26,3 +26,15 @@ export const AGENT_SESSION_RESTART_CONTINUATION_MESSAGE = */ export const AGENT_SESSION_RESTART_CONTINUATION_NOTE = 'Orca asked this agent to continue after a restart. Your own prompt was not re-sent.' + +/** Host-authored notes left in a chat the continuation did not carry on, so the chat itself says + * what happened and what to do. The next message the user sends is the manual continuation. */ +export const AGENT_SESSION_RESTART_CONTINUATION_REFUSED_NOTE = + "Orca couldn't continue this chat after the restart. Send a message to continue it." +export const AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE = + "Orca asked this agent to continue after the restart but couldn't confirm it did. Check its latest reply before sending another message." + +/** For a chat Orca could not get hold of. Why decides the fix, which the restart list gives; advice + * to send a message would meet the same refusal. */ +export const AGENT_SESSION_RESTART_NOT_CONNECTED_NOTE = + "Orca couldn't reconnect this chat after the restart, so it didn't ask the agent to continue." diff --git a/src/shared/agent-session-resume-marker.ts b/src/shared/agent-session-resume-marker.ts index dbf566fbc62..6508aab502a 100644 --- a/src/shared/agent-session-resume-marker.ts +++ b/src/shared/agent-session-resume-marker.ts @@ -18,6 +18,12 @@ export type AgentSessionResumeTrigger = (typeof AGENT_SESSION_RESUME_TRIGGERS)[n * the user has long since forgotten, and an obligation with no expiry strands forever. */ export const AGENT_SESSION_RESUME_MARKER_TTL_MS = 24 * 60 * 60 * 1000 +/** How an acted-on offer ended without the agent carrying on. `refused` is a definite no from the + * host or provider; `unconfirmed` means the continuation may have gone out and nothing proved it. */ +export const AGENT_SESSION_RESUME_FAILURE_OUTCOMES = ['refused', 'unconfirmed'] as const +export type AgentSessionResumeFailureOutcome = + (typeof AGENT_SESSION_RESUME_FAILURE_OUTCOMES)[number] + /** * WHAT the session was working on, in whichever identity that work actually had. * diff --git a/src/shared/rpc-contract/structured-agent-session-params.ts b/src/shared/rpc-contract/structured-agent-session-params.ts index bcdc2a98630..40f3cf43745 100644 --- a/src/shared/rpc-contract/structured-agent-session-params.ts +++ b/src/shared/rpc-contract/structured-agent-session-params.ts @@ -250,9 +250,13 @@ export const HoldParams = z .object({ sessionId: SessionId, holderId: Identifier('Invalid holder id') }) .strict() -/** A launch's offer to resume what the last teardown recorded as working. No arguments: the set is - * the host's to derive, never a client's to assert. */ -export const RestartResumableParams = z.object({}).strict() +/** A launch's offer to resume what the last teardown recorded as working; the set is the host's to + * derive, never a client's to assert. Listing takes nothing. Dismissing takes the sessions to + * forget, or nothing to forget them all; a client only ever names sessions the host itself listed, + * so an older host that rejects the key is never asked to. */ +export const RestartResumableParams = z + .object({ sessionIds: z.array(SessionId).max(MAX_RESTART_RESUME_SESSIONS).optional() }) + .strict() /** Omitting `sessionIds` takes the whole offered set; naming them takes that subset. Either way the * host re-derives eligibility, so an id a client invents is simply not in the set. */ 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 13d0419da73..0de322c1b1d 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 @@ -19,6 +19,7 @@ export function structuredHostStub( // `installableHost` below is what reassembles the member. Keeping them flat also lets the // manifest name them to prove a call reached the host. restartResumableList: vi.fn(async () => []), + restartResumableFailures: vi.fn(async () => []), restartResumableDismiss: vi.fn(async () => 0), restartResumeAll: vi.fn(async () => []), restartContinueAll: vi.fn(async () => ({ resumed: [], continued: [] })), @@ -103,6 +104,7 @@ export function installableHost( ...hostCalls, restartResume: { list: hostCalls.restartResumableList, + listFailures: hostCalls.restartResumableFailures, dismiss: hostCalls.restartResumableDismiss, resume: hostCalls.restartResumeAll, continueAfterRestart: hostCalls.restartContinueAll