diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-holds.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-holds.ts index 027c2d54772..73880022646 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-holds.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-holds.ts @@ -60,23 +60,25 @@ export class StructuredAgentSessionHolds { // Unconditional, not only on the first-holder edge: a second surface arriving during the grace // window must cancel the pending release too. this.clock.cancel(sessionId) - if (options.resume === false || this.deps.hasProviderChild(sessionId)) { + if (options.resume === false) { return } - try { - await this.deps.resume(sessionId) - if (!this.deps.hasProviderChild(sessionId)) { - throw new Error('agent_session_ownership_unknown') + if (!this.deps.hasProviderChild(sessionId)) { + try { + await this.deps.resume(sessionId) + if (!this.deps.hasProviderChild(sessionId)) { + throw new Error('agent_session_ownership_unknown') + } + // The last surface can disconnect before acquisition makes a child available to release. + if (!this.disposed && !this.holders.isHeld(sessionId)) { + this.clock.arm(sessionId) + } + } catch (error) { + if (!alreadyHeld && incarnation !== undefined) { + this.release(sessionId, holderId, incarnation) + } + throw error } - // The last surface can disconnect before acquisition makes a child available to release. - if (!this.disposed && !this.holders.isHeld(sessionId)) { - this.clock.arm(sessionId) - } - } catch (error) { - if (!alreadyHeld && incarnation !== undefined) { - this.release(sessionId, holderId, incarnation) - } - throw error } } 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 new file mode 100644 index 00000000000..6eda6b63587 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-candidates.ts @@ -0,0 +1,86 @@ +// Which of a set of markers the predicate would still act on, read off the host's own live journals. +// +// A different question from storage: the durable record decides which markers are still present; +// this decides which of those describe work a resume may touch. The offer, the click and the +// teardown write-back all ask it, each with a different lease expectation. +// +// The per-session journal snapshot is cached for the length of one call: the predicate asks the +// same session for its items four times, and a snapshot that moved between those reads would let +// two clauses judge two different conversations. + +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import type { AgentJournalRenderItem } from '../../../shared/agent-session-journal-types' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' +import { + latestStructuredAgentSessionPrompt, + latestStructuredAgentSessionUserItem, + newestStructuredAgentSessionTurn, + projectStructuredAgentSessionStatus +} from '../../../shared/structured-agent-session-projection' +import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import { adapterSupportsRecord } from './structured-agent-session-provider-support' +import { + structuredAgentSessionResumableSet, + type StructuredAgentSessionResumeCandidate +} from './structured-agent-session-restart-resume-set' + +/** The only part of a live session this reads. */ +export type StructuredAgentSessionRestartJournalSource = { journal: AgentSessionJournal } + +export type StructuredAgentSessionRestartCandidateOptions = { + /** Only teardown may judge before it rewrites the stopped child's running turn. */ + providerStopped?: boolean + /** A continuation already in flight; its own submission is not newer user work. */ + pendingContinuationId?: string +} + +export type StructuredAgentSessionRestartCandidateReader = ( + markers: readonly AgentSessionResumeMarker[], + leaseState: 'must-be-released' | 'may-be-held', + options?: StructuredAgentSessionRestartCandidateOptions +) => StructuredAgentSessionResumeCandidate[] + +export function createStructuredAgentSessionRestartCandidateReader(deps: { + /** The host's LIVE session map — the only honest answer to "was this actually working". */ + sessions: ReadonlyMap + getRecord: (sessionId: string) => AgentSessionRecord | null + adapter: StructuredAgentSessionAdapter + now: () => number +}): StructuredAgentSessionRestartCandidateReader { + return (markers, leaseState, options = {}) => { + const items = new Map() + const itemsFor = (sessionId: string): AgentJournalRenderItem[] => { + let snapshot = items.get(sessionId) + if (!snapshot) { + snapshot = deps.sessions.get(sessionId)?.journal.snapshot().items ?? [] + if (options.pendingContinuationId) { + const ownItemId = agentJournalSubmissionKey(options.pendingContinuationId) + snapshot = snapshot.filter((item) => item.itemId !== ownItemId) + } + items.set(sessionId, snapshot) + } + return snapshot + } + return structuredAgentSessionResumableSet({ + markers, + getRecord: deps.getRecord, + supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record), + waitingOnUser: (sessionId) => + projectStructuredAgentSessionStatus(itemsFor(sessionId)) === 'attention', + providerStopped: options.providerStopped === true, + journalTurn: (sessionId) => newestStructuredAgentSessionTurn(itemsFor(sessionId)), + journalSubmission: (sessionId, clientMessageId) => + deps.sessions + .get(sessionId) + ?.journal.submissions() + .find((submission) => submission.clientMessageId === clientMessageId) ?? null, + latestPrompt: (sessionId) => latestStructuredAgentSessionPrompt(itemsFor(sessionId)), + latestUserItemId: (sessionId) => + latestStructuredAgentSessionUserItem(itemsFor(sessionId))?.itemId ?? null, + now: deps.now(), + leaseState + }) + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-claim.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-claim.test.ts deleted file mode 100644 index 376d1385d1c..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-claim.test.ts +++ /dev/null @@ -1,463 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' -import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' -import { - AGENT_SESSION_RESTART_CONTINUATION_MESSAGE, - AGENT_SESSION_RESTART_CONTINUATION_NOTE -} from '../../../shared/agent-session-restart-continuation' -import { createStructuredAgentSessionRestartResume } from './structured-agent-session-restart-resume-host' -import { - HANDLE_ROOT, - journal, - liveLeaseRecord, - marker, - NOW, - record, - SESSION, - submission, - turnItem, - type HarnessSession -} from './structured-agent-session-restart-resume-test-harness' - -function surface(input: { - markers?: AgentSessionResumeMarker[] - sessions?: Map - record?: AgentSessionRecord - holdFails?: boolean - clearFails?: boolean - /** Orca refused to take the message at all. */ - sendRefusal?: AgentSessionWireRefusal - /** - * The dispatch state SETTLEMENT reports. The send itself always answers `pending`, because that - * is what the real host does: it resolves as soon as Orca owns the message, before the provider - * has answered. Defaults to the delivered case. - */ - settledDispatch?: 'pending' | 'accepted' | 'rejected' | 'unknown' - settledReason?: string - /** Nothing settled the send in time, which the waiter reports by resolving undefined. */ - settlementTimesOut?: boolean - noteFails?: boolean -}) { - const live = new Map((input.markers ?? [marker()]).map((entry) => [entry.sessionId, entry])) - const recorded: AgentSessionResumeMarker[][] = [] - const held: string[] = [] - const noted: { sessionId: string; text: string }[] = [] - const store = { - getRecord: () => input.record ?? record() - } - const recoveryCapsule = { - record: async (markers: readonly AgentSessionResumeMarker[]) => { - recorded.push([...markers]) - live.clear() - markers.forEach((entry) => live.set(entry.sessionId, entry)) - }, - take: vi.fn(async () => { - if (input.clearFails) { - throw new Error('durable store refused the clear') - } - const markers = [...live.values()] - live.clear() - return markers - }) - } - const sent: { sessionId: string; text: string }[] = [] - const sessions = - input.sessions ?? - new Map([ - [ - SESSION, - { - journal: journal([turnItem('turn-1', 'interrupted')]), - hasProviderChild: false, - fence: 1 - } - ] - ]) - // The note is written onto the session's own journal; intercept it there to assert attribution. - for (const [sessionId, session] of sessions) { - session.journal.appendItem = async (_envelope, body) => { - if (input.noteFails) { - throw new Error('journal refused the note') - } - noted.push({ sessionId, text: body.text }) - } - } - const noteFailures: { sessionId: string; error: unknown }[] = [] - return { - restartResume: createStructuredAgentSessionRestartResume( - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the collaborator reads only getRecord from the store, and supportsCreate from the adapter. - { - store, - adapter: { supportsCreate: () => true }, - recoveryCapsule - } as never, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the live-session map is read for journal, hasProviderChild and fence only. - sessions as never, - { - publish: () => {}, - revealSession: async () => ({ readable: true }), - release: () => {}, - hold: async (sessionId: string) => { - if (input.holdFails) { - throw new Error('provider refused the reconnect') - } - held.push(sessionId) - }, - send: async ({ envelope, body }) => { - sent.push({ - sessionId: envelope.sessionId, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: restartContinuationBody builds exactly one text block, which is what this assertion reads. - text: (body.blocks[0] as { text: string }).text - }) - if (input.sendRefusal) { - return { ok: false, refusal: input.sendRefusal } - } - // What the real send answers: Orca owns the message, the provider has not replied yet. - return { - ok: true, - replayed: false, - fence: 1, - cursor: { epoch: 'epoch-1', sequence: 1 }, - value: { - clientMessageId: envelope.clientOperationId, - submission: submission(envelope.clientOperationId, 'pending') - } - } - }, - awaitSendSettlement: async (_sessionId: string, clientMessageId: string) => - input.settlementTimesOut - ? undefined - : { - value: { - clientMessageId, - submission: { - ...submission(clientMessageId, input.settledDispatch ?? 'accepted'), - reason: input.settledReason ?? null - } - } - }, - onNoteFailed: (sessionId: string, error: unknown) => - noteFailures.push({ sessionId, error }), - now: () => NOW - } - ), - live, - recorded, - held, - sent, - noted, - noteFailures, - recoveryCapsule - } -} - -describe('claiming the recovery capsule', () => { - it('reports a failed take without logging private capsule or filesystem details', async () => { - const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) - try { - const { restartResume, recoveryCapsule, held, sent } = surface({}) - recoveryCapsule.take.mockRejectedValueOnce(new Error('private capsule payload and path')) - expect(await restartResume.list()).toEqual([]) - expect(await restartResume.continueAfterRestart([SESSION], 'modal')).toEqual({ - resumed: [], - continued: [] - }) - expect(held).toEqual([]) - expect(sent).toEqual([]) - expect(warning).toHaveBeenCalledWith( - '[structured-agent-session] taking recovery capsule failed' - ) - expect(warning.mock.calls.flat().map(String).join(' ')).not.toContain('private capsule') - } finally { - warning.mockRestore() - } - }) - - it('shares one take across concurrent initial readers', async () => { - const { restartResume, recoveryCapsule } = surface({}) - const results = await Promise.all([restartResume.list(), restartResume.list()]) - expect(results.map((items) => items.length)).toEqual([1, 1]) - expect(recoveryCapsule.take).toHaveBeenCalledTimes(1) - }) - - // The claim is the deletion. Everything on disk goes in one step — including markers this launch - // refuses — so a later launch has nothing left to re-examine. - it('deletes every durable marker at claim time, refused ones included', async () => { - const { restartResume, live } = surface({ - markers: [marker(), marker({ sessionId: 'session-working-2', teardownId: 'launch-older' })] - }) - - await restartResume.list() - - expect(live.size).toBe(0) - }) - - // Fail closed again: if the delete throws, the markers are still live on disk, so acting on them - // would be acting on something a later launch can also act on. - it('claims nothing when the durable clear fails', async () => { - const { restartResume, held } = surface({ clearFails: true }) - - expect(await restartResume.list()).toEqual([]) - expect(await restartResume.resume(undefined, 'modal')).toEqual([]) - expect(held).toEqual([]) - }) -}) - -describe('the restart-resume surface', () => { - it('renders one journal snapshot per marked session when listing an offer', async () => { - const sessionJournal = journal([turnItem('turn-1', 'interrupted')]) - const snapshot = vi.spyOn(sessionJournal, 'snapshot') - const { restartResume } = surface({ - sessions: new Map([[SESSION, { journal: sessionJournal, hasProviderChild: false, fence: 1 }]]) - }) - - expect(await restartResume.list()).toHaveLength(1) - expect(snapshot).toHaveBeenCalledTimes(1) - }) - - // The structural guarantee behind "the checkbox can never continue": the reconnect path contains - // no send at all, so no setting, and no automatic launch, can turn it into a continuation. - it('never sends a message when reconnecting', async () => { - const { restartResume, held, sent } = surface({}) - - await restartResume.resume(undefined, 'modal') - - expect(held).toEqual([SESSION]) - expect(sent).toEqual([]) - }) - - it('reconnects and then sends exactly one continuation carrying the shared message', async () => { - const { restartResume, held, sent } = surface({}) - - const result = await restartResume.continueAfterRestart(undefined, 'modal') - - expect(held).toEqual([SESSION]) - expect(sent).toEqual([{ sessionId: SESSION, text: AGENT_SESSION_RESTART_CONTINUATION_MESSAGE }]) - expect(result.continued).toEqual([{ sessionId: SESSION, outcome: 'continued' }]) - }) - - // The predicate refused it, so it is not even a candidate and the loop never sees it. - it('sends nothing to a session that was never eligible', async () => { - const { restartResume, sent } = surface({ - markers: [marker({ work: { kind: 'turn', id: 'turn-elsewhere' } })] - }) - - const result = await restartResume.continueAfterRestart(undefined, 'modal') - - expect(sent).toEqual([]) - expect(result.continued).toEqual([]) - }) - - // The case that actually exercises the gate: an ELIGIBLE session whose reconnect failed. It - // reaches the loop as a refused outcome, and continuation must still not send to it. - it('sends nothing to a session that did not reconnect', async () => { - const { restartResume, sent, held } = surface({ holdFails: true }) - - const result = await restartResume.continueAfterRestart(undefined, 'modal') - - expect(held).toEqual([]) - expect(sent).toEqual([]) - expect(result.continued).toEqual([ - { sessionId: SESSION, outcome: 'refused', reason: 'provider refused the reconnect' } - ]) - }) - - it('offers and resumes an eligible session', async () => { - const { restartResume, held } = surface({}) - - expect(await restartResume.list()).toHaveLength(1) - await restartResume.resume(undefined, 'modal') - - expect(held).toEqual([SESSION]) - }) - - // Turning the prompt down must not leave anything that can bring it back next launch — including - // a marker that was never eligible in the first place. - it('spends every live marker on dismiss, eligible or not', async () => { - const ineligible = marker({ - sessionId: 'session-working-2', - work: { kind: 'turn', id: 'turn-elsewhere' } - }) - const { restartResume, live } = surface({ markers: [marker(), ineligible] }) - - expect(await restartResume.dismiss()).toBe(2) - - expect(live.size).toBe(0) - expect(await restartResume.list()).toEqual([]) - }) - - // TOCTOU: the chat's own pane binds between the offer and the click, the lease goes live, and the - // predicate drops the session. Reporting "nothing happened" would leave the user pressing a dead - // button for a session that IS running. - it('reports a session the chat pane already re-acquired as resumed, not as nothing', async () => { - const { restartResume, held } = surface({ - record: liveLeaseRecord(), - sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'interrupted')]), hasProviderChild: true }] - ]) - }) - - const outcomes = await restartResume.resume([SESSION], 'modal') - - expect(outcomes).toEqual([{ sessionId: SESSION, outcome: 'resumed' }]) - // Already-live sessions consume the same offer and use the same temporary request hold. - expect(await restartResume.dismiss()).toBe(0) - expect(held).toEqual([SESSION]) - }) - - // Relaxing the lease clause must not relax the whole predicate. "Resume all" targets every - // marker, so a held-but-ineligible session would otherwise be consumed and counted as resumed. - it('refuses to settle an already-live session the predicate rejects', async () => { - const { restartResume, held } = surface({ - record: liveLeaseRecord(), - sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-1', 'completed')]), hasProviderChild: true }] - ]) - }) - - const outcomes = await restartResume.resume(undefined, 'modal') - - expect(outcomes).toEqual([]) - // The claim survives unspent: nothing was resumed, so nothing may be spent. - expect(await restartResume.dismiss()).toBe(1) - expect(held).toEqual([]) - }) - - // The client names ids; only the host decides which of them may have a provider child. - it('resumes nothing for a session id the caller invented', async () => { - const { restartResume, held } = surface({}) - - const outcomes = await restartResume.resume(['session-not-offered-1'], 'modal') - - expect(outcomes).toEqual([]) - expect(held).toEqual([]) - }) - - // Quitting while the prompt is open: the offered session has no provider child in THIS - // generation, so teardown mints no marker for it and the replace-the-whole-set write clears the - // old one. The offer is discarded rather than resurrected, and nothing can double-fire. - it('leaves no marker behind when the user quits with the offer still open', async () => { - const { restartResume, live, recorded } = surface({}) - - restartResume.captureMarkers('quit') - await restartResume.recordMarkers() - - expect(recorded).toEqual([[]]) - expect(live.size).toBe(0) - }) - - it('re-marks a session whose resume is already running when the next quit lands', async () => { - const { restartResume, recorded } = surface({ - sessions: new Map([ - [SESSION, { journal: journal([turnItem('turn-2', 'running')]), hasProviderChild: true }] - ]) - }) - - restartResume.captureMarkers('update') - restartResume.confirmStoppedMarker(SESSION) - await restartResume.recordMarkers() - - expect(recorded[0]).toEqual([ - { - sessionId: SESSION, - work: { kind: 'turn', id: 'turn-2' }, - recordedAt: NOW, - trigger: 'update', - teardownId: expect.any(String), - providerHandleRoot: HANDLE_ROOT, - latestUserItemId: null - } - ]) - }) -}) - -describe('reporting what the continuation actually did', () => { - // THE REGRESSION. A send resolves as soon as Orca owns the message, while its dispatch is still - // `pending` — the ordinary successful path, not an edge case. Judging the dispatch on the send - // result therefore calls every delivered continuation `pending` and never writes the note. This - // fixture answers `pending` from send and `accepted` from settlement, exactly as the host does, - // so a version that reads the send result fails here. - it('waits for settlement before judging, so a delivered continuation is not read as pending', async () => { - const { restartResume, noted } = surface({ settledDispatch: 'accepted' }) - - const result = await restartResume.continueAfterRestart(undefined, 'modal') - - expect(result.continued).toEqual([{ sessionId: SESSION, outcome: 'continued' }]) - expect(noted).toHaveLength(1) - expect(noted[0]?.sessionId).toBe(SESSION) - expect(noted[0]?.text).toBe(AGENT_SESSION_RESTART_CONTINUATION_NOTE) - }) - - // The provider's own answer lives inside the submission. Reading only the envelope reports a - // refused turn/start as continued and stamps the journal saying the agent was asked to carry on. - it('reports a rejected dispatch as refused and writes no note', async () => { - const { restartResume, noted } = surface({ - settledDispatch: 'rejected', - settledReason: 'provider_turn_start_refused' - }) - - const result = await restartResume.continueAfterRestart(undefined, 'modal') - - expect(result.continued).toEqual([ - { sessionId: SESSION, outcome: 'refused', reason: 'provider_turn_start_refused' } - ]) - expect(noted).toEqual([]) - }) - - it('reports a send Orca could not hand off as refused and writes no note', async () => { - const { restartResume, noted } = surface({ - sendRefusal: { code: 'agent_session_conflict', message: 'the runtime moved on' } - }) - - const result = await restartResume.continueAfterRestart(undefined, 'modal') - - expect(result.continued).toEqual([ - { sessionId: SESSION, outcome: 'refused', reason: 'agent_session_conflict' } - ]) - expect(noted).toEqual([]) - }) - - // Settlement gave up and the dispatch is STILL pending: handed off, never confirmed. - it('reports a dispatch still pending after settlement as pending, with no note', async () => { - const { restartResume, noted } = surface({ settledDispatch: 'pending' }) - - const result = await restartResume.continueAfterRestart(undefined, 'modal') - - expect(result.continued).toEqual([{ sessionId: SESSION, outcome: 'pending' }]) - expect(noted).toEqual([]) - }) - - it('reports an unverifiable dispatch as unknown and writes no note', async () => { - const { restartResume, noted } = surface({ settledDispatch: 'unknown' }) - - const result = await restartResume.continueAfterRestart(undefined, 'modal') - - expect(result.continued).toEqual([{ sessionId: SESSION, outcome: 'unknown' }]) - expect(noted).toEqual([]) - }) - - // A waiter that timed out answers undefined, leaving only the send's own `pending`. That is not - // proof of delivery either, so it must not fall through into success. - it('claims no delivery when nothing ever settled the send', async () => { - const { restartResume, noted } = surface({ settlementTimesOut: true }) - - const result = await restartResume.continueAfterRestart(undefined, 'modal') - - expect(result.continued).toEqual([{ sessionId: SESSION, outcome: 'pending' }]) - expect(noted).toEqual([]) - }) - - // The note stays best effort — a journal that refuses it must not turn a delivered continuation - // into a failure — but its failure is REPORTED. This is the second silent swallow on this feature. - it('reports a note it could not write instead of swallowing the failure', async () => { - const { restartResume, noted, noteFailures } = surface({ noteFails: true }) - - const result = await restartResume.continueAfterRestart(undefined, 'modal') - - expect(result.continued).toEqual([{ sessionId: SESSION, outcome: 'continued' }]) - expect(noted).toEqual([]) - expect(noteFailures).toHaveLength(1) - expect(noteFailures[0]?.sessionId).toBe(SESSION) - }) -}) 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 new file mode 100644 index 00000000000..e7b1b13ac3c --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-continuation.test.ts @@ -0,0 +1,84 @@ +import { expect, it, vi } from 'vitest' +import { marker, SESSION } from './structured-agent-session-restart-resume-test-harness' +import { + continueStructuredAgentSessionAfterRestart, + type StructuredAgentSessionContinuationDeps +} from './structured-agent-session-restart-continuation' + +function dependencies( + settledDispatch: 'accepted' | 'pending' | 'unknown' | 'rejected' +): StructuredAgentSessionContinuationDeps & { + note: ReturnType + send: ReturnType +} { + return { + currentFence: () => 1, + send: vi.fn(async () => ({ + ok: true, + value: { submission: { dispatchState: 'pending' } } + })), + awaitSettlement: vi.fn(async () => ({ + dispatchState: settledDispatch, + reason: settledDispatch === 'rejected' ? 'provider_refused' : null + })), + note: vi.fn(async () => undefined), + onNoteFailed: vi.fn() + } +} + +it('reports an accepted continuation and records its note', async () => { + const deps = dependencies('accepted') + + await expect( + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker()) + ).resolves.toEqual({ + sessionId: SESSION, + outcome: 'continued' + }) + expect(deps.note).toHaveBeenCalledOnce() +}) + +it.each([ + ['pending', { sessionId: SESSION, outcome: 'pending' }], + ['unknown', { sessionId: SESSION, outcome: 'unknown' }], + ['rejected', { sessionId: SESSION, outcome: 'refused', reason: 'provider_refused' }] +] as const)( + 'preserves a %s settlement without recording a success note', + async (settled, expected) => { + const deps = dependencies(settled) + + await expect( + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker()) + ).resolves.toEqual(expected) + expect(deps.note).not.toHaveBeenCalled() + } +) + +it('reports a send refusal without waiting for settlement', async () => { + const deps = dependencies('accepted') + deps.send.mockResolvedValue({ ok: false, refusal: { code: 'agent_session_conflict' } }) + + await expect( + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker()) + ).resolves.toEqual({ + sessionId: SESSION, + outcome: 'refused', + reason: 'agent_session_conflict' + }) + expect(deps.awaitSettlement).not.toHaveBeenCalled() + expect(deps.note).not.toHaveBeenCalled() +}) + +it('reports an unattached chat without sending', async () => { + const deps = dependencies('accepted') + deps.currentFence = () => null + + await expect( + continueStructuredAgentSessionAfterRestart(deps, SESSION, marker()) + ).resolves.toEqual({ + sessionId: SESSION, + outcome: 'refused', + reason: 'agent_session_not_attached' + }) + expect(deps.send).not.toHaveBeenCalled() +}) 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 5d77b82019d..22dfcdff882 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 @@ -1,10 +1,9 @@ -// Asking an interrupted agent to carry on — always, and only, on a deliberate user action. +// Asking an interrupted agent to carry on, on the user's opt-in. // -// Reconnecting and continuing are SEPARATE operations. Reconnect reattaches and sends nothing; this -// adds one message on top of a reconnect, and only when the user pressed a control that says so. -// The automatic-reconnect setting cannot reach this module — the resume surface it calls has no -// send in it at all — so "the checkbox never continues" is structural rather than wiring -// discipline. +// Reattaching and continuing are SEPARATE operations: `resume` reattaches and sends nothing; this +// adds one message on top of it. Both the restart prompt and an opted-in launch come here, so a +// SETTING can reach this send — acceptable because the work is the user's own, the message asks the +// 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' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-operation-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-operation-queue.ts new file mode 100644 index 00000000000..159a59110f3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-operation-queue.ts @@ -0,0 +1,14 @@ +/** Serializes advisory restart mutations without holding the lane during provider work. */ +export function createStructuredAgentSessionRestartOperationQueue(): ( + operation: () => Promise +) => Promise { + let previous: Promise = Promise.resolve() + return (operation: () => Promise): Promise => { + const result = previous.then(operation, operation) + previous = result.then( + () => undefined, + () => undefined + ) + return result + } +} 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 e3108e556ea..6cfe9dad1d7 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 @@ -87,7 +87,7 @@ async function interruptedRestart( const capsule = JSON.parse( await readFile(join(previous.root, AGENT_SESSION_RECOVERY_CAPSULE_FILE), 'utf8') ) - const marker = parseAgentSessionResumeMarker(capsule.markers[0]) + const marker = parseAgentSessionResumeMarker(capsule.entries[0]?.marker) return { ...hostTestState(), host, store, closeSession, marker } } @@ -324,7 +324,8 @@ it.each([ expect(host.isHeld(SESSION)).toBe(false) expect(await host.restartResume.continueAfterRestart([SESSION], 'retry')).toEqual({ resumed: [], - continued: [] + continued: [], + sessions: [] }) expect(dispatch).not.toHaveBeenCalled() if (settlementFails) { @@ -455,7 +456,7 @@ it.each([false, true])( } ) -it('releases a failed acquisition without retrying the spent offer', async () => { +it('releases a failed acquisition and leaves the offer retryable', async () => { const { host, acquire, dispatch, closeSession } = await interruptedRestart() acquire.mockRejectedValueOnce(new Error('provider could not reconnect')) expect(await host.restartResume.resume([SESSION], 'modal')).toMatchObject([ @@ -463,8 +464,8 @@ it('releases a failed acquisition without retrying the spent offer', async () => ]) expect(host.isHeld(SESSION)).toBe(false) await host.restartResume.continueAfterRestart([SESSION], 'retry') - expect(acquire).toHaveBeenCalledTimes(1) - expect(dispatch).not.toHaveBeenCalled() + expect(acquire).toHaveBeenCalledTimes(2) + expect(dispatch).toHaveBeenCalledTimes(1) expect(closeSession).not.toHaveBeenCalled() }) @@ -489,7 +490,7 @@ it.each([false, true])( expect(acquire).toHaveBeenCalledTimes(1) expect(dispatch).toHaveBeenCalledTimes(1) expect(host.journalSnapshot(SESSION).submissions).toHaveLength(1) - expect(await new AgentSessionRecoveryCapsule(root).take(NOW)).toEqual([]) + expect(await new AgentSessionRecoveryCapsule(root).list(NOW)).toEqual([]) await host.restartResume.continueAfterRestart([SESSION], 'later-click') expect(dispatch).toHaveBeenCalledTimes(1) host.release(SESSION, 'pane') @@ -540,14 +541,85 @@ it('retains acquisition through slow continuation settlement, then releases it', expect(dispatch).toHaveBeenCalledTimes(1) }) -it('shares one real-file take across concurrent first recovery requests', async () => { +// Opening the chat is inspection only. The explicit restart action is what removes the durable +// offer, so ordinary pane lifecycle must not make this status disappear. +it('keeps offering a chat after the user opens it', async () => { + const { host, root, store } = await interruptedRestart() + expect(await host.restartResume.list()).toHaveLength(1) + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + await host.hold(SESSION, 'pane') + host.release(SESSION, 'pane') + await vi.advanceTimersByTimeAsync(GRACE) + // The whole eviction, not just the provider stop: the lease returns to `released` on the step + // before the last, and until it does the offer is refused for a reason that is not recovery. + await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false)) + expect(store.getRecord(SESSION)?.lease.claimStatus).toBe('released') + vi.useRealTimers() + + expect(await host.restartResume.list()).toHaveLength(1) + await host.restartResume.recordMarkers() + expect(await new AgentSessionRecoveryCapsule(root).list(NOW)).toHaveLength(1) +}) + +// The snooze, through the real quit path rather than a session map that cannot move: eviction +// forgets sessions BEFORE the write-back runs, so a marker whose journal is only reachable while +// the host still indexes it is exactly what a mock harness cannot catch. +it('carries a snoozed offer through a real teardown', async () => { const { host, root } = await interruptedRestart() - const take = vi.spyOn(AgentSessionRecoveryCapsule.prototype, 'take') + expect(await host.restartResume.list()).toHaveLength(1) + await host.flushAllStreamedEvents({ trigger: 'quit' }) + expect(await new AgentSessionRecoveryCapsule(root).list(NOW)).toHaveLength(1) +}) + +// Nothing acted on the capsule this launch, so it is still exactly as the last teardown left it. +it('keeps a durable offer intact through a teardown with no explicit action', async () => { + const { host, root } = await interruptedRestart() + await host.flushAllStreamedEvents({ trigger: 'quit' }) + expect(await new AgentSessionRecoveryCapsule(root).list(NOW)).toHaveLength(1) +}) + +it('serializes teardown publication behind an explicit dismissal', async () => { + await attach() + const { host, root, acquire } = hostTestState() + const events = acquire.mock.calls[0]?.[0].events + if (!events) { + throw new Error('missing provider event sink') + } + events.appendItem( + { provider: 'codex', threadId: THREAD, turnId: 'working', ordinal: 1 }, + { kind: 'turn', turnId: 'working', state: 'running' } + ) + await host.flushStreamedEvents(SESSION) + host.restartResume.captureMarkers('quit') + host.restartResume.confirmStoppedMarker(SESSION) + + const releaseRecord = Promise.withResolvers() + const originalRecord = AgentSessionRecoveryCapsule.prototype.record + const record = vi.spyOn(AgentSessionRecoveryCapsule.prototype, 'record') + record.mockImplementation(function (this: AgentSessionRecoveryCapsule, ...args) { + return releaseRecord.promise.then(() => originalRecord.apply(this, args)) + }) + + try { + const recording = host.restartResume.recordMarkers() + await Promise.resolve() + const dismissed = host.restartResume.dismiss() + releaseRecord.resolve() + await Promise.all([recording, dismissed]) + expect(await new AgentSessionRecoveryCapsule(root).list(NOW)).toEqual([]) + } finally { + record.mockRestore() + } +}) + +it('keeps concurrent recovery reads independent and non-destructive', async () => { + const { host, root } = await interruptedRestart() + const list = vi.spyOn(AgentSessionRecoveryCapsule.prototype, 'list') const results = await Promise.all([host.restartResume.list(), host.restartResume.list()]) expect(results.map((items) => items.length)).toEqual([1, 1]) - expect(take).toHaveBeenCalledTimes(1) - expect(await new AgentSessionRecoveryCapsule(root).take(NOW)).toEqual([]) - take.mockRestore() + expect(list).toHaveBeenCalledTimes(2) + expect(await new AgentSessionRecoveryCapsule(root).list(NOW)).toHaveLength(1) + list.mockRestore() }) it('fails closed on corrupt recovery storage while ordinary hold and send still work', async () => { @@ -557,7 +629,8 @@ it('fails closed on corrupt recovery storage while ordinary hold and send still expect(await host.restartResume.list()).toEqual([]) expect(await host.restartResume.continueAfterRestart([SESSION], 'modal')).toEqual({ resumed: [], - continued: [] + continued: [], + sessions: [] }) await host.hold(SESSION, 'pane') const body = hostTestMessage('A fresh ordinary request') @@ -565,7 +638,7 @@ 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(1) + expect(warning).toHaveBeenCalledTimes(3) warning.mockRestore() host.release(SESSION, 'pane') }) 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 bd19bf147bd..2524b10d250 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,24 +1,14 @@ -// Recovery offers live in memory only after an atomic take of the advisory capsule. -// A crash after take loses the offer; ordinary chat acquisition remains independent. +// Restart offers are durable per-session records. Listing is read-only; only an explicit action +// reserves records, and only a completed action removes them. import { randomUUID } from 'node:crypto' -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import type { AgentSessionResumeMarker, AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' -import { - latestStructuredAgentSessionPrompt, - latestStructuredAgentSessionUserItem, - newestStructuredAgentSessionTurn, - projectStructuredAgentSessionStatus -} from '../../../shared/structured-agent-session-projection' -import type { - AgentJournalMessageItem, - AgentJournalRenderItem -} from '../../../shared/agent-session-journal-types' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import type { AgentSessionMutationEnvelope, AgentSessionMutationResult, @@ -26,11 +16,9 @@ import type { } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import { adapterSupportsRecord } from './structured-agent-session-provider-support' -import { - structuredAgentSessionResumableSet, - type StructuredAgentSessionResumeCandidate -} from './structured-agent-session-restart-resume-set' +import { createStructuredAgentSessionRestartCandidateReader } from './structured-agent-session-restart-candidates' +import { createStructuredAgentSessionRestartOperationQueue } from './structured-agent-session-restart-operation-queue' +import type { StructuredAgentSessionResumeCandidate } from './structured-agent-session-restart-resume-set' import { resumeStructuredAgentSessionsFromRestart, StructuredAgentSessionResumeAdmission, @@ -45,30 +33,20 @@ import { structuredAgentSessionsWorkingAtTeardown } from './structured-agent-ses type LiveSession = { journal: AgentSessionJournal; hasProviderChild: boolean; fence: number } -/** The host capabilities this needs, named so the collaborator cannot quietly grow more. */ export type StructuredAgentSessionRestartResumeSurfaces = { publish: (sessionId: string, journal: AgentSessionJournal) => void revealSession: (sessionId: string) => Promise<{ readable: boolean }> - /** The resume-capable hold; see the runner for why a hold and not a send. */ hold: (sessionId: string, holderId: string) => Promise release: (sessionId: string, holderId: string) => void - /** The host's own send. Reached ONLY from `continueAfterRestart` — `resume` never calls it, which - * is what makes "automatic reconnect can never continue" structural. - * - * Typed against the wire result rather than a hand-written subset: an narrower local shape hid - * `value.submission` here once, and the continuation reads it. */ send: (input: { envelope: AgentSessionMutationEnvelope body: AgentJournalMessageItem beforeRun?: () => void }) => Promise> - /** The host's existing settlement waiter. A send resolves while its dispatch is still pending, so - * this is what turns that starting state into a verdict. */ awaitSendSettlement: ( sessionId: string, clientMessageId: string ) => Promise<{ value: AgentSessionSendResult } | undefined> - /** Where a failed journal note is reported. */ onNoteFailed: (sessionId: string, error: unknown) => void now: () => number } @@ -82,13 +60,13 @@ export type StructuredAgentSessionRestartResume = { sessionIds: readonly string[] | undefined, owner: string ) => Promise - /** Reconnect, then ask each reconnected agent to carry on. A deliberate user action only. */ continueAfterRestart: ( sessionIds: readonly string[] | undefined, owner: string ) => Promise<{ resumed: StructuredAgentSessionResumeOutcome[] continued: StructuredAgentSessionContinuationOutcome[] + sessions?: StructuredAgentSessionResumeCandidate[] }> dismiss: () => Promise } @@ -97,9 +75,11 @@ export function createStructuredAgentSessionRestartResume( deps: { store: AgentSessionRecordStore adapter: StructuredAgentSessionAdapter - recoveryCapsule?: Pick + recoveryCapsule?: Pick< + AgentSessionRecoveryCapsule, + 'list' | 'record' | 'beginResume' | 'completeResume' | 'rollbackResume' | 'clearAll' + > }, - /** The host's LIVE session map — the only honest answer to "was this actually working". */ sessions: ReadonlyMap, surfaces: StructuredAgentSessionRestartResumeSurfaces ): StructuredAgentSessionRestartResume { @@ -107,138 +87,136 @@ export function createStructuredAgentSessionRestartResume( const teardownId = randomUUID() let teardownMarkers = new Map() const confirmedMarkers = new Map() - let claimed: AgentSessionResumeMarker[] | null = null - let claiming: Promise | undefined + const enqueueRecoveryOperation = createStructuredAgentSessionRestartOperationQueue() - const claimMarkers = async (): Promise => { - claiming ??= (async () => { - try { - claimed = (await deps.recoveryCapsule?.take(surfaces.now())) ?? [] - } catch { - console.warn('[structured-agent-session] taking recovery capsule failed') - claimed = [] - } - })() - await claiming - return claimed ?? [] + const derive = createStructuredAgentSessionRestartCandidateReader({ + sessions, + getRecord: deps.store.getRecord, + adapter: deps.adapter, + now: surfaces.now + }) + + const readMarkers = async (): Promise => { + try { + return (await deps.recoveryCapsule?.list(surfaces.now())) ?? [] + } catch { + // Recovery is advisory. A malformed capsule must not make ordinary chat actions unusable; + // the durable bytes stay untouched so an explicit dismissal can remove them. + console.warn('[structured-agent-session] reading recovery capsule failed') + return [] + } } - /** Spends one claimed marker. In memory, because the durable copy is already gone. */ - const spendClaimed = (sessionId: string): boolean => { - const before = claimed?.length ?? 0 - claimed = (claimed ?? []).filter((marker) => marker.sessionId !== sessionId) - return claimed.length < before - } - - /** Opens any claimed session this launch has not, so its journal can answer for itself. An - * unreadable journal leaves the predicate with one record instead of two, which refuses. */ - const revealClaimed = async (): Promise => { - const markers = await claimMarkers() + const revealMarkers = async (markers: readonly AgentSessionResumeMarker[]): Promise => { for (const marker of markers) { if (!sessions.has(marker.sessionId)) { await surfaces.revealSession(marker.sessionId).catch(() => null) } } - return markers } - const derive = ( - markers: readonly AgentSessionResumeMarker[], - leaseState: 'must-be-released' | 'may-be-held', - providerStopped = false, - pendingContinuationId?: string - ): StructuredAgentSessionResumeCandidate[] => { - const items = new Map() - const itemsFor = (sessionId: string): AgentJournalRenderItem[] => { - let snapshot = items.get(sessionId) - if (!snapshot) { - snapshot = sessions.get(sessionId)?.journal.snapshot().items ?? [] - if (pendingContinuationId) { - const ownItemId = agentJournalSubmissionKey(pendingContinuationId) - snapshot = snapshot.filter((item) => item.itemId !== ownItemId) - } - items.set(sessionId, snapshot) - } - return snapshot - } - return structuredAgentSessionResumableSet({ - markers, - getRecord: deps.store.getRecord, - supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record), - waitingOnUser: (sessionId) => - projectStructuredAgentSessionStatus(itemsFor(sessionId)) === 'attention', - providerStopped, - journalTurn: (sessionId) => newestStructuredAgentSessionTurn(itemsFor(sessionId)), - journalSubmission: (sessionId, clientMessageId) => - sessions - .get(sessionId) - ?.journal.submissions() - .find((submission) => submission.clientMessageId === clientMessageId) ?? null, - latestPrompt: (sessionId) => latestStructuredAgentSessionPrompt(itemsFor(sessionId)), - latestUserItemId: (sessionId) => - latestStructuredAgentSessionUserItem(itemsFor(sessionId))?.itemId ?? null, - now: surfaces.now(), - leaseState - }) + const list = async (): Promise => { + const markers = await readMarkers() + await revealMarkers(markers) + // A live chat remains an offer. The user may have opened it to inspect the context and still + // explicitly choose whether Orca should ask the agent to continue. + return derive(markers, 'may-be-held') } - const list = async (): Promise => - derive(await revealClaimed(), 'must-be-released') - const run = async ( sessionIds: readonly string[] | undefined, owner: string, afterAcquire?: (marker: AgentSessionResumeMarker) => Promise ): Promise => { - const markers = await revealClaimed() - const requested = new Set(sessionIds ?? markers.map((entry) => entry.sessionId)) - // Re-derived at CLICK time, never taken from the caller: a client may name any session id, - // and only the predicate decides which of them is allowed a provider child. - const released = new Set(derive(markers, 'must-be-released').map((entry) => entry.sessionId)) - const candidates = derive(markers, 'may-be-held').filter( - (candidate) => - requested.has(candidate.sessionId) && - (released.has(candidate.sessionId) || - sessions.get(candidate.sessionId)?.hasProviderChild === true) + // 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() + await revealMarkers(markers) + const requested = new Set(sessionIds ?? markers.map((marker) => marker.sessionId)) + const eligible = derive(markers, 'may-be-held').filter((candidate) => + requested.has(candidate.sessionId) ) - const markersBySession = new Map(markers.map((marker) => [marker.sessionId, marker])) - return resumeStructuredAgentSessionsFromRestart( - { - admission, - consumeMarker: async (sessionId) => { - const marker = markersBySession.get(sessionId) - const leaseState = - sessions.get(sessionId)?.hasProviderChild === true ? 'may-be-held' : 'must-be-released' - return !!marker && derive([marker], leaseState).length === 1 && spendClaimed(sessionId) - }, - resume: async (sessionId) => { - const holder = `restart-resume:${sessionId}` - try { - await surfaces.hold(sessionId, holder) + if (eligible.length === 0) { + return [] + } + const operationId = randomUUID() + const reserved = + (await enqueueRecoveryOperation( + () => + deps.recoveryCapsule?.beginResume( + eligible.map((candidate) => candidate.sessionId), + operationId, + surfaces.now() + ) ?? Promise.resolve([]) + )) ?? [] + const markersBySession = new Map(reserved.map((marker) => [marker.sessionId, marker])) + const candidates = derive(reserved, 'may-be-held') + + let outcomes: StructuredAgentSessionResumeOutcome[] + try { + outcomes = await resumeStructuredAgentSessionsFromRestart( + { + admission, + consumeMarker: async (sessionId) => { const marker = markersBySession.get(sessionId) - if (marker) { - await afterAcquire?.(marker) + return marker !== undefined && derive([marker], 'may-be-held').length === 1 + }, + resume: async (sessionId) => { + const holder = `restart-resume:${sessionId}` + try { + await surfaces.hold(sessionId, holder) + const marker = markersBySession.get(sessionId) + if (marker) { + await afterAcquire?.(marker) + } + } finally { + surfaces.release(sessionId, holder) } - } finally { - // Pane holds and active turns take over; otherwise the normal idle grace applies. - surfaces.release(sessionId, holder) } - } - }, - candidates, - owner - ) + }, + candidates, + owner + ) + } catch (error) { + if (deps.recoveryCapsule) { + await enqueueRecoveryOperation(() => + deps.recoveryCapsule!.rollbackResume(operationId, surfaces.now()) + ).catch(() => { + console.warn('[structured-agent-session] restart offer rollback failed') + }) + } + 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') + }) + } + return outcomes } - /** Reconnect first, then send. Continuation is a message ON TOP of a reconnect and reuses every - * guard the resume path applies — eligibility, the admission gate, staggering, consume-once — - * rather than re-deriving any of them. A session that did not reconnect is never sent to. */ const continueAfterRestart = async ( sessionIds: readonly string[] | undefined, owner: string ): Promise<{ resumed: StructuredAgentSessionResumeOutcome[] continued: StructuredAgentSessionContinuationOutcome[] + sessions?: StructuredAgentSessionResumeCandidate[] }> => { const continued: StructuredAgentSessionContinuationOutcome[] = [] const resumed = await run(sessionIds, owner, async (marker) => { @@ -249,12 +227,9 @@ export function createStructuredAgentSessionRestartResume( send: (input) => surfaces.send({ ...input, - // The pending continuation itself is not newer user work. beforeRun: () => { - if ( - derive([marker], 'may-be-held', false, input.envelope.clientOperationId) - .length !== 1 - ) { + const options = { pendingContinuationId: input.envelope.clientOperationId } + if (derive([marker], 'may-be-held', options).length !== 1) { throw new RestartContinuationSupersededError() } } @@ -292,7 +267,17 @@ export function createStructuredAgentSessionRestartResume( }) } } - return { resumed, continued } + let remainingCandidates: StructuredAgentSessionResumeCandidate[] | undefined + try { + remainingCandidates = await list() + } catch { + console.warn('[structured-agent-session] restart offer refresh failed after action') + } + return { + resumed, + continued, + ...(remainingCandidates === undefined ? {} : { sessions: remainingCandidates }) + } } return { @@ -311,32 +296,29 @@ export function createStructuredAgentSessionRestartResume( }, confirmStoppedMarker: (sessionId) => { const marker = teardownMarkers.get(sessionId) - // Eviction has stopped the provider and drained its tail, but has not cancelled prompts yet. teardownMarkers.delete(sessionId) try { - if (marker && derive([marker], 'may-be-held', true).length === 1) { + 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 () => - deps.recoveryCapsule?.record([...confirmedMarkers.values()], surfaces.now()), + recordMarkers: async () => { + await enqueueRecoveryOperation(async () => { + await deps.recoveryCapsule?.record([...confirmedMarkers.values()], surfaces.now()) + }) + }, list, - /** - * Turning the offer down, which spends the claim. - * - * A prompt that returns at every launch is worse than the problem it solves. Nothing is lost: - * the first resume-capable hold on a childless session re-acquires the provider at the same - * proved cursor, so opening the chat still reconnects it. The durable markers are already gone - * — the claim deleted them — so this only has to empty the launch-scoped set. - */ dismiss: async () => { - const markers = await claimMarkers() - const spent = markers.length - claimed = [] - return spent + 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 + }) }, 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 2dda0332cff..21ab253c46c 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 @@ -1,9 +1,11 @@ -// Spending the markers: the one path that turns a resumable candidate back into a live agent. +// Delivering the explicit restart action: the one path that turns a resumable candidate back into +// a live agent. // // The manual "Resume" button and the automatic setting both land here, so the two can never drift // into different eligibility or different double-fire protection. // -// Resume itself is a HOLD, not a send. The first resume-capable hold on a childless session +// Resume itself acquires a provider child, not a new send. The first resume-capable hold on a +// childless session // re-acquires the provider at the cursor the record already proved — Claude's `resume` + // `resumeSessionAt`, Codex's thread id — which is native continuation. Nothing re-sends the user's // prompt: that is what makes an agent redo work it already finished. @@ -74,9 +76,9 @@ export class StructuredAgentSessionResumeAdmission { export type StructuredAgentSessionResumeRunnerDeps = { admission: StructuredAgentSessionResumeAdmission - /** Spends the runtime claim. False means another request already took it. */ + /** Validates this action's durable reservation. False means the candidate is no longer eligible. */ consumeMarker: (sessionId: string) => Promise - /** Takes the resume-capable hold that re-acquires the provider child. */ + /** Acquires the provider child for the reserved session. */ resume: (sessionId: string) => Promise concurrency?: number } @@ -104,10 +106,14 @@ async function resumeOne( ): Promise { try { return await deps.admission.run(sessionId, owner, async () => { - // Consumed BEFORE the hold, not after it succeeds. A crash between the two costs one resume - // the user can start by hand; the other order costs them the same agent running twice. + // Validate BEFORE provider acquisition. The durable reservation is removed only after the + // action succeeds, and a failed acquisition reopens it for the next explicit attempt. if (!(await deps.consumeMarker(sessionId))) { - return { sessionId, outcome: 'refused' as const, reason: 'agent_session_resume_consumed' } + return { + sessionId, + outcome: 'refused' as const, + reason: 'agent_session_resume_not_eligible' + } } await deps.resume(sessionId) return { sessionId, outcome: 'resumed' as const } 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 3816307dfdc..3a6af73ab5d 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 @@ -1,4 +1,4 @@ -// Which claimed teardown witnesses still describe resumable work. +// Which durable teardown witnesses still describe resumable work. // // Every clause here exists to refuse, and the bias is deliberate: a session resumed that should not // have been spends the user's tokens and can make an agent redo destructive work it already diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts index 066b5477649..3b23849c0e1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness.ts @@ -1,6 +1,6 @@ // Fixtures shared by the restart-resume suites: one durable record, one journal, one marker. // -// Kept in one place so the predicate suite and the claim suite cannot drift into disagreeing about +// Kept in one place so the predicate suite and the host suite cannot drift into disagreeing about // what a resumable session looks like — a divergence there would let one suite pass on a shape the // other rejects. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts index 6f2ffae7f07..8319f912430 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume.test.ts @@ -559,7 +559,7 @@ describe('spending a marker', () => { ) expect(outcomes).toEqual([ - { sessionId: SESSION, outcome: 'refused', reason: 'agent_session_resume_consumed' } + { sessionId: SESSION, outcome: 'refused', reason: 'agent_session_resume_not_eligible' } ]) expect(resume).toHaveBeenCalledOnce() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts index c1113b345f7..2332a221e8b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-teardown.test.ts @@ -66,7 +66,7 @@ it.each(['approval', 'question', 'completed'])( { lifecycle: true } ) await host.flushAllStreamedEvents() - expect(await new AgentSessionRecoveryCapsule(root).take(NOW)).toEqual([]) + expect(await new AgentSessionRecoveryCapsule(root).list(NOW)).toEqual([]) } ) @@ -99,6 +99,6 @@ it.each(['approval', 'question', 'completed'] as const)( return true } await host.flushAllStreamedEvents() - expect(await new AgentSessionRecoveryCapsule(root).take(NOW)).toEqual([]) + expect(await new AgentSessionRecoveryCapsule(root).list(NOW)).toEqual([]) } ) diff --git a/src/main/runtime/agent-session-recovery-capsule.test.ts b/src/main/runtime/agent-session-recovery-capsule.test.ts index 3899b8f3a9f..75de1d285ad 100644 --- a/src/main/runtime/agent-session-recovery-capsule.test.ts +++ b/src/main/runtime/agent-session-recovery-capsule.test.ts @@ -1,19 +1,14 @@ -import { mkdir, mkdtemp, readFile, readdir, rm, utimes, writeFile } from 'node:fs/promises' +import { mkdtemp, readFile, readdir, rm, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AGENT_SESSION_RESUME_MARKER_TTL_MS } from '../../shared/agent-session-resume-marker' import * as durable from '../durable-file-write' -import { withFileTransactionLock } from '../file-transaction-lock' import { marker, NOW, - record, SESSION } from '../native-chat/agent-session-wire/structured-agent-session-restart-resume-test-harness' -import { AgentSessionRecordStore } from './agent-session-record-store' -import { agentSessionStorePath } from './agent-session-record-store-file' -import * as serialization from './agent-session-store-serialization' import { AgentSessionRecoveryCapsule, AGENT_SESSION_RECOVERY_CAPSULE_FILE @@ -22,11 +17,13 @@ import { let directory: string let filePath: string let capsule: AgentSessionRecoveryCapsule + beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-recovery-capsule-')) filePath = join(directory, AGENT_SESSION_RECOVERY_CAPSULE_FILE) capsule = new AgentSessionRecoveryCapsule(directory) }) + afterEach(async () => { vi.restoreAllMocks() await rm(directory, { recursive: true, force: true }) @@ -36,159 +33,289 @@ function failPublish() { return vi.spyOn(durable, 'renameDurable').mockRejectedValueOnce(new Error('publish unavailable')) } -async function seedConversationStore( - legacyMarkers: unknown = { - [SESSION]: { ...marker(), teardownId: undefined, launchId: 'legacy-launch' } - } -) { - const storeDirectory = join(directory, 'records') - await mkdir(storeDirectory) - const storePath = agentSessionStorePath(storeDirectory) - const payload = JSON.stringify({ - schemaVersion: 2, - hostId: 'local', - records: { [SESSION]: record() }, - operations: {}, - retiredClaimKeys: [], - unusableRecords: {}, - resumeMarkers: legacyMarkers - }) - await writeFile(storePath, payload) - await writeFile(`${storePath}.bak`, payload) - return { storeDirectory, storePath, payload } -} - -describe('non-backed-up recovery capsule', () => { - it('publishes the complete set and gives it to only one successful take', async () => { +describe('durable restart offers', () => { + it('lists without spending or rewriting the durable records', async () => { const markers = [marker(), marker({ sessionId: 'second' })] await capsule.record(markers, NOW) - expect(await new AgentSessionRecoveryCapsule(directory).take(NOW)).toEqual(markers) - expect(await capsule.take(NOW)).toEqual([]) - expect(await readdir(directory)).toEqual([AGENT_SESSION_RECOVERY_CAPSULE_FILE]) + const before = await readFile(filePath) + + expect(await capsule.list(NOW)).toEqual(markers) + expect(await capsule.list(NOW)).toEqual(markers) + expect(await readFile(filePath)).toEqual(before) }) - it('replaces the previous witness set in one publication', async () => { - await capsule.record([marker()], NOW) - const replacement = marker({ sessionId: 'replacement', teardownId: 'teardown-b' }) - await capsule.record([replacement], NOW) - expect(await capsule.take(NOW)).toEqual([replacement]) + it('merges fresh teardown markers with existing offers by session', async () => { + const replacement = marker({ teardownId: 'teardown-new', recordedAt: NOW + 1 }) + await capsule.record([marker(), marker({ sessionId: 'second' })], NOW) + await capsule.record([replacement], NOW + 1) + + expect(await capsule.list(NOW + 1)).toEqual([replacement, marker({ sessionId: 'second' })]) }) - it('allows only one runtime to take across competing file-lock owners', async () => { - await capsule.record([marker()], NOW) + it('does not let a late older teardown replace a newer offer', async () => { + const older = marker({ recordedAt: NOW }) + const newer = marker({ recordedAt: NOW + 1, teardownId: 'teardown-new' }) + await capsule.record([newer], NOW + 1) + await capsule.record([older], NOW + 1) + + expect(await capsule.list(NOW + 1)).toEqual([newer]) + }) + + it('reserves only one copy of a selected session across competing owners', async () => { + await capsule.record([marker(), marker({ sessionId: 'second' })], NOW) + const other = new AgentSessionRecoveryCapsule(directory) + const results = await Promise.allSettled([ - capsule.take(NOW), - new AgentSessionRecoveryCapsule(directory).take(NOW) + capsule.beginResume([SESSION], 'operation-a', NOW), + other.beginResume([SESSION], 'operation-b', NOW) ]) - const taken = results.flatMap((result) => (result.status === 'fulfilled' ? result.value : [])) - expect(taken).toEqual([marker()]) - expect(await capsule.take(NOW)).toEqual([]) + const selected = results.flatMap((result) => + result.status === 'fulfilled' ? result.value : [] + ) + + // Bounded lock retries serialize the two mutations; one owner wins and the other observes an + // empty selection after the winner publishes its reservation. + expect(selected).toHaveLength(1) + expect(await capsule.list(NOW)).toEqual([marker({ sessionId: 'second' })]) }) - it('cannot resurrect consumed A after B fails publication and C succeeds', async () => { - await capsule.record([marker({ teardownId: 'A' })], NOW) - expect(await capsule.take(NOW)).toHaveLength(1) + it('deletes only completed sessions and leaves unrelated offers', async () => { + await capsule.record([marker(), marker({ sessionId: 'second' })], NOW) + const reserved = await capsule.beginResume([SESSION], 'operation-a', NOW) + expect(reserved).toEqual([marker()]) + + await capsule.completeResume('operation-a', [SESSION], NOW) + + expect(await capsule.list(NOW)).toEqual([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) + expect(await capsule.list(NOW)).toEqual([]) + + await capsule.rollbackResume('operation-a', NOW) + + expect(await capsule.list(NOW)).toEqual([marker()]) + }) + + it('does not let another operation complete or roll back an active reservation', async () => { + await capsule.record([marker()], NOW) + await capsule.beginResume([SESSION], 'operation-a', NOW) + + await capsule.completeResume('operation-b', [SESSION], NOW) + await capsule.rollbackResume('operation-b', NOW) + expect(await capsule.list(NOW)).toEqual([]) + + await capsule.rollbackResume('operation-a', NOW) + expect(await capsule.list(NOW)).toEqual([marker()]) + }) + + it('preserves an active reservation when teardown records another session', async () => { + await capsule.record([marker()], NOW) + await capsule.beginResume([SESSION], 'operation-a', NOW) + await capsule.record([marker({ sessionId: 'second' })], NOW) + + expect(await capsule.list(NOW)).toEqual([marker({ sessionId: 'second' })]) + const raw = JSON.parse(await readFile(filePath, 'utf8')) + expect(raw.entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + state: 'in-progress', + operationId: 'operation-a', + marker: expect.objectContaining({ sessionId: SESSION }) + }), + expect.objectContaining({ + state: 'pending', + marker: expect.objectContaining({ sessionId: 'second' }) + }) + ]) + ) + }) + + it('keeps a newer same-session teardown behind an active reservation', async () => { + const newer = marker({ + teardownId: 'teardown-new', + recordedAt: NOW + 1, + work: { kind: 'turn', id: 'turn-new' } + }) + await capsule.record([marker()], NOW) + await capsule.beginResume([SESSION], 'operation-a', NOW) + await capsule.record([newer], NOW + 1) + + expect(await capsule.list(NOW + 1)).toEqual([]) + await capsule.rollbackResume('operation-a', NOW + 1) + expect(await capsule.list(NOW + 1)).toEqual([newer]) + + await capsule.beginResume([SESSION], 'operation-b', NOW) + await capsule.completeResume('operation-b', [SESSION], NOW) + expect(await capsule.list(NOW)).toEqual([]) + }) + + it('reclaims an expired in-progress reservation on the next mutation', async () => { + await capsule.record([marker()], NOW) + await capsule.beginResume([SESSION], 'operation-a', NOW) + const later = NOW + 10 * 60 * 1000 + 1 + + // A read remains read-only, but the expired reservation is visible as a pending offer. + expect(await capsule.list(later)).toEqual([marker()]) + const reserved = await capsule.beginResume([SESSION], 'operation-b', later) + expect(reserved).toEqual([marker()]) + await capsule.completeResume('operation-b', [SESSION], later) + expect(await capsule.list(later)).toEqual([]) + }) + + it('dismisses pending and active recovery records', async () => { + await capsule.record([marker(), marker({ sessionId: 'second' })], NOW) + await capsule.beginResume([SESSION], 'operation-a', NOW) + + expect(await capsule.clearAll(NOW)).toBe(1) + expect(await capsule.list(NOW)).toEqual([]) + + // A later failed action cannot resurrect a record the user explicitly dismissed. + await capsule.rollbackResume('operation-a', NOW) + expect(await capsule.list(NOW)).toEqual([]) + }) + + it('fences a late teardown write until a genuinely newer interruption', async () => { + await capsule.record([marker()], NOW) + await capsule.clearAll(NOW + 1) + + // This is the stale callback that can outlive a timed teardown phase in another host. + await capsule.record([marker()], NOW + 1) + expect(await capsule.list(NOW + 1)).toEqual([]) + + const newer = marker({ recordedAt: NOW + 2, teardownId: 'teardown-new' }) + await capsule.record([newer], NOW + 2) + expect(await capsule.list(NOW + 2)).toEqual([newer]) + + // A new interruption must not remove the fence before the old callback has finished. + await capsule.record([marker()], NOW + 2) + expect(await capsule.list(NOW + 2)).toEqual([newer]) + }) + + it('keeps a legacy v1 file readable until a mutating operation migrates it', async () => { + const legacy = JSON.stringify({ version: 1, markers: [marker()] }) + await writeFile(filePath, legacy) + + expect(await capsule.list(NOW)).toEqual([marker()]) + expect(await readFile(filePath, 'utf8')).toBe(legacy) + + await capsule.record([marker({ sessionId: 'second' })], NOW) + expect(JSON.parse(await readFile(filePath, 'utf8'))).toMatchObject({ version: 2 }) + expect(await capsule.list(NOW)).toEqual([marker(), marker({ sessionId: 'second' })]) + }) + + it('preserves unreadable bytes on list and ordinary writes', async () => { + const corrupt = '{"version":2,"entries":[{"state":"pending"}]}' + await writeFile(filePath, corrupt) + + await expect(capsule.list(NOW)).rejects.toThrow() + await expect(capsule.record([marker()], NOW)).rejects.toThrow() + expect(await readFile(filePath, 'utf8')).toBe(corrupt) + }) + + it('fails closed on duplicate session records instead of allowing a second resume', async () => { + const duplicate = JSON.stringify({ + version: 2, + entries: [ + { state: 'in-progress', operationId: 'operation-a', startedAt: NOW, marker: marker() }, + { state: 'pending', marker: marker({ teardownId: 'duplicate' }) } + ] + }) + await writeFile(filePath, duplicate) + + await expect(capsule.list(NOW)).rejects.toThrow('duplicate_session') + expect(await readFile(filePath, 'utf8')).toBe(duplicate) + }) + + it('lets explicit dismissal replace an unreadable file with an empty fence', async () => { + await writeFile(filePath, '{') + + expect(await capsule.clearAll(NOW)).toBe(0) + await expect(capsule.list(NOW)).resolves.toEqual([]) + expect(JSON.parse(await readFile(filePath, 'utf8'))).toMatchObject({ + version: 2, + entries: [], + dismissedAt: NOW + }) + }) + + it('preserves prior records when a durable publication fails', async () => { + await capsule.record([marker()], NOW) const publish = failPublish() - await expect(capsule.record([marker({ teardownId: 'B' })], NOW)).rejects.toThrow( + + await expect(capsule.record([marker({ sessionId: 'second' })], NOW)).rejects.toThrow( 'publish unavailable' ) publish.mockRestore() - const third = new AgentSessionRecoveryCapsule(directory) - expect(await third.take(NOW)).toEqual([]) - await third.record([marker({ teardownId: 'C' })], NOW) - expect(await new AgentSessionRecoveryCapsule(directory).take(NOW)).toEqual([ - marker({ teardownId: 'C' }) - ]) + expect(await capsule.list(NOW)).toEqual([marker()]) }) - it.each(['{', JSON.stringify({ version: 1, markers: [marker(), { sessionId: 5 }] })])( - 'refuses a corrupt capsule without exposing partial candidates: %s', - async (raw) => { - await writeFile(filePath, raw) - await expect(capsule.take(NOW)).rejects.toThrow() - } - ) - - it('refuses failed reads', async () => { - await mkdir(filePath) - await expect(capsule.take(NOW)).rejects.toThrow() - }) - - it('exposes nothing on failed clear and permits a later successful take of unclaimed work', async () => { + it('preserves a pending offer when reservation publication fails', async () => { await capsule.record([marker()], NOW) const publish = failPublish() - await expect(capsule.take(NOW)).rejects.toThrow('publish unavailable') + + await expect(capsule.beginResume([SESSION], 'operation-a', NOW)).rejects.toThrow( + 'publish unavailable' + ) publish.mockRestore() - expect(await new AgentSessionRecoveryCapsule(directory).take(NOW)).toEqual([marker()]) + expect(await capsule.list(NOW)).toEqual([marker()]) + }) + + it('preserves an active reservation when completion publication fails', async () => { + await capsule.record([marker()], NOW) + await capsule.beginResume([SESSION], 'operation-a', NOW) + const publish = failPublish() + + await expect(capsule.completeResume('operation-a', [SESSION], NOW)).rejects.toThrow( + 'publish unavailable' + ) + publish.mockRestore() + expect(await capsule.list(NOW)).toEqual([]) + + await capsule.rollbackResume('operation-a', NOW) + expect(await capsule.list(NOW)).toEqual([marker()]) }) it.each([NOW - AGENT_SESSION_RESUME_MARKER_TTL_MS - 1, NOW + 1])( - 'consumes but refuses a witness outside its freshness window (%s)', + 'does not list an expired or future marker (%s)', async (recordedAt) => { await capsule.record([marker({ recordedAt })], recordedAt) - expect(await capsule.take(NOW)).toEqual([]) - expect(await capsule.take(recordedAt)).toEqual([]) + expect(await capsule.list(NOW)).toEqual([]) } ) - it.each([false, true])( - 'ignores legacy markers without losing conversation data (backup: %s)', - async (backup) => { - const { storeDirectory, storePath } = await seedConversationStore() - await capsule.record([marker()], NOW) - expect(await capsule.take(NOW)).toHaveLength(1) - if (backup) { - await writeFile(storePath, '{') - } - const store = await AgentSessionRecordStore.open({ - directory: storeDirectory, - hostId: 'local' - }) - expect(store.getRecord(SESSION)?.sessionId).toBe(SESSION) - expect(store.getRecord(SESSION)?.providerHandleChain).toEqual(record().providerHandleChain) - expect(await new AgentSessionRecoveryCapsule(directory).take(NOW)).toEqual([]) - await store.setSessionTabVisibility(SESSION, true) - expect(JSON.parse(await readFile(storePath, 'utf8')).resumeMarkers).toBeUndefined() - } - ) + it('reclaims stale durable-write debris without touching unrelated files', async () => { + const abandoned = `${filePath}.0.1.abandoned.tmp` + const recent = `${filePath}.0.2.recent.tmp` + const unrelated = join(directory, 'conversation.tmp') + await Promise.all([abandoned, recent, unrelated].map((path) => writeFile(path, 'debris'))) + const old = new Date(Date.now() - AGENT_SESSION_RESUME_MARKER_TTL_MS - 1000) + await utimes(abandoned, old, old) - it('keeps advisory I/O off session serialization, backups and the session transaction lock', async () => { - const { storePath, payload } = await seedConversationStore() - const records = Object.fromEntries( - Array.from({ length: 2000 }, (_, index) => { - const sessionId = `session-${index}` - const entry = record() - return [sessionId, { ...entry, sessionId, lease: { ...entry.lease, sessionId } }] - }) + await capsule.record([marker()], NOW) + + expect(await readdir(directory)).toEqual( + expect.arrayContaining([ + AGENT_SESSION_RECOVERY_CAPSULE_FILE, + `${AGENT_SESSION_RECOVERY_CAPSULE_FILE}.0.2.recent.tmp`, + 'conversation.tmp' + ]) + ) + expect(await readdir(directory)).not.toContain( + `${AGENT_SESSION_RECOVERY_CAPSULE_FILE}.0.1.abandoned.tmp` ) - const largePayload = JSON.stringify({ ...JSON.parse(payload), records }) - expect(Buffer.byteLength(largePayload)).toBeGreaterThan(1_000_000) - await writeFile(storePath, largePayload) - const serialize = vi.spyOn(serialization, 'serializeAgentSessionStoreState') - const backup = vi.spyOn(durable, 'copyFileDurable') - const writes = vi.spyOn(durable, 'writeTempFileDurable') - await withFileTransactionLock(storePath, async () => { - await capsule.record([marker()], NOW) - expect(await capsule.take(NOW)).toEqual([marker()]) - }) - expect(serialize).not.toHaveBeenCalled() - expect(backup).not.toHaveBeenCalled() - expect(writes).toHaveBeenCalledTimes(2) - expect( - writes.mock.calls.reduce((bytes, call) => bytes + Buffer.byteLength(call[1]), 0) - ).toBeLessThan(1024) - expect(await readFile(storePath, 'utf8')).toBe(largePayload) - expect(await readFile(`${storePath}.bak`, 'utf8')).toBe(payload) }) }) -it('has no idle work or retained lock timers after publication, take, or failure', async () => { +it('does not retain lock timers after reads, writes, or failed publications', async () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] }) try { expect(vi.getTimerCount()).toBe(0) await capsule.record([marker()], NOW) expect(vi.getTimerCount()).toBe(0) - await capsule.take(NOW) + await capsule.list(NOW) expect(vi.getTimerCount()).toBe(0) failPublish() await expect(capsule.record([marker()], NOW)).rejects.toThrow() @@ -197,23 +324,3 @@ it('has no idle work or retained lock timers after publication, take, or failure vi.useRealTimers() } }) - -it('reclaims expired publication debris without touching recent or unrelated files', async () => { - const abandoned = `${filePath}.0.1.abandoned.tmp` - const recent = `${filePath}.0.2.recent.tmp` - const unrelated = join(directory, 'conversation.tmp') - await Promise.all([abandoned, recent, unrelated].map((path) => writeFile(path, 'debris'))) - const old = new Date(Date.now() - AGENT_SESSION_RESUME_MARKER_TTL_MS - 1000) - await utimes(abandoned, old, old) - await capsule.record([marker()], NOW) - expect(await readdir(directory)).toEqual( - expect.arrayContaining([ - AGENT_SESSION_RECOVERY_CAPSULE_FILE, - `${AGENT_SESSION_RECOVERY_CAPSULE_FILE}.0.2.recent.tmp`, - 'conversation.tmp' - ]) - ) - expect(await readdir(directory)).not.toContain( - `${AGENT_SESSION_RECOVERY_CAPSULE_FILE}.0.1.abandoned.tmp` - ) -}) diff --git a/src/main/runtime/agent-session-recovery-capsule.ts b/src/main/runtime/agent-session-recovery-capsule.ts index 3601ebd3949..c7575749288 100644 --- a/src/main/runtime/agent-session-recovery-capsule.ts +++ b/src/main/runtime/agent-session-recovery-capsule.ts @@ -19,10 +19,127 @@ import { withFileTransactionLock } from '../file-transaction-lock' export const AGENT_SESSION_RECOVERY_CAPSULE_FILE = 'agent-session-recovery.json' const MAX_CAPSULE_BYTES = 4 * 1024 * 1024 -const capsuleSchema = z.object({ version: z.literal(1), markers: z.array(z.unknown()) }) +const RESUME_ACTION_LEASE_TTL_MS = 10 * 60 * 1000 -/** Advisory authorization has no backup: successful take spends it before acquisition. - * A failed take exposes nothing; an unclaimed witness may survive until a later take or expiry. */ +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 +} + +/** Durable, per-session restart offers. Listing never spends an offer. */ export class AgentSessionRecoveryCapsule { private readonly filePath: string @@ -30,47 +147,146 @@ export class AgentSessionRecoveryCapsule { this.filePath = join(stateDirectory, AGENT_SESSION_RECOVERY_CAPSULE_FILE) } + list(now: number): Promise { + return withFileTransactionLock(this.filePath, async () => { + const entries = normalizeEntries((await this.readState()).entries, now) + return entries.filter((entry) => entry.state === 'pending').map((entry) => entry.marker) + }) + } + + /** Adds fresh teardown witnesses while preserving an action already in progress. */ record(markers: readonly AgentSessionResumeMarker[], now: number): Promise { - return withFileTransactionLock( - this.filePath, - () => - this.publish(markers.filter((marker) => !isExpiredAgentSessionResumeMarker(marker, now))), - { retries: 0 } - ) - } - - take(now: number): Promise { - return withFileTransactionLock( - this.filePath, - async () => { - let raw: string - try { - raw = (await readNodeFileWithinLimit(this.filePath, MAX_CAPSULE_BYTES)).buffer.toString( - 'utf8' - ) - } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { - return [] - } - throw error + return withFileTransactionLock(this.filePath, async () => { + const state = await this.readState() + const entries = normalizeEntries(state.entries, now) + const bySession = new Map(entries.map((entry) => [entry.marker.sessionId, entry])) + const dismissedAt = state.dismissedAt + for (const marker of markers) { + if ( + isExpiredAgentSessionResumeMarker(marker, now) || + (dismissedAt !== undefined && marker.recordedAt <= dismissedAt) + ) { + continue } - const capsule = capsuleSchema.parse(JSON.parse(raw)) - const markers = capsule.markers.map((value) => { - const marker = parseAgentSessionResumeMarker(value) - if (!marker) { - throw new Error('agent_session_recovery_capsule_invalid') + const existing = bySession.get(marker.sessionId) + if (existing?.state === 'in-progress') { + const current = existing.replacement ?? existing.marker + if ( + shouldReplaceMarker(current, marker) && + (marker.recordedAt > current.recordedAt || marker.teardownId !== current.teardownId) + ) { + bySession.set(marker.sessionId, { ...existing, replacement: marker }) } - return marker - }) - await this.publish([]) - return markers.filter((marker) => !isExpiredAgentSessionResumeMarker(marker, now)) - }, - { retries: 0 } - ) + continue + } + if (existing && !shouldReplaceMarker(existing.marker, marker)) { + continue + } + 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) + }) } - private async publish(markers: readonly AgentSessionResumeMarker[]): Promise { - const { serialized } = stringifyJsonWithinByteLimit({ version: 1, markers }, MAX_CAPSULE_BYTES) + /** Reserves only the selected pending sessions for one explicit user action. */ + beginResume( + sessionIds: readonly string[] | undefined, + operationId: string, + now: number + ): Promise { + return withFileTransactionLock(this.filePath, async () => { + const state = await this.readState() + const entries = normalizeEntries(state.entries, 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 + } + selected.push(entry.marker) + return { state: 'in-progress' as const, operationId, startedAt: now, marker: entry.marker } + }) + await this.publish(next, state.dismissedAt) + return selected + }) + } + + 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) + ) { + return [entry] + } + return entry.replacement ? [{ state: 'pending' as const, marker: entry.replacement }] : [] + }) + await this.publish(entries, 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) + }) + } + + clearAll(now: number): Promise { + return withFileTransactionLock(this.filePath, async () => { + let entries: RecoveryEntry[] + try { + entries = normalizeEntries((await this.readState()).entries, now) + } 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) + 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) + return pending.length + }) + } + + private async readState(): Promise { + let raw: string + try { + raw = (await readNodeFileWithinLimit(this.filePath, MAX_CAPSULE_BYTES)).buffer.toString( + 'utf8' + ) + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return { entries: [] } + } + throw error + } + return parseState(raw) + } + + private async publish(entries: readonly RecoveryEntry[], dismissedAt?: number): Promise { + const { serialized } = stringifyJsonWithinByteLimit( + { version: 2, entries, ...(dismissedAt === undefined ? {} : { dismissedAt }) }, + MAX_CAPSULE_BYTES + ) await removeStaleDurableWriteTempFiles(this.filePath, { minimumAgeMs: AGENT_SESSION_RESUME_MARKER_TTL_MS }) 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 60636136b37..36d7e35eb5e 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 @@ -1,8 +1,9 @@ -// `agentSession.restartResumable` / `agentSession.restartResume` — the restart-resume offer. +// The restart-resume offer: list it, act on it, or turn it down. // -// Both reach for records on disk this process may not have opened yet, so they build the host the -// way hold and reveal do. Listing is read-only and takes nothing live; resuming goes through the -// host's single resume path, which re-derives eligibility rather than trusting the ids it is given. +// Each method reaches for records on disk this process may not have opened yet, so each builds the +// host the way hold and reveal do. Listing is read-only and takes nothing live; acting goes through +// the host's single resume path, which re-derives eligibility rather than trusting the ids it is +// given. import { defineMethod } from '../core' import { @@ -22,19 +23,23 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ } }), defineMethod({ - // Spends the markers without resuming; see the collaborator for why turning the offer down - // consumes it rather than leaving it to return at every launch. + // Explicitly abandons the markers without resuming. Closing the dialog is a snooze and does + // not call this method, so the status-bar entry can reopen the offer later. name: 'agentSession.restartResumableDismiss', params: RestartResumableParams, handler: async (_params, ctx) => { await ensureStructuredHostInstalled(ctx) - return { dismissed: await requireStructuredHost(ctx).restartResume.dismiss() } + 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: [] } } }), defineMethod({ - // Reconnect AND ask each reconnected agent to carry on. Separate from `restartResume` on - // purpose: that method sends nothing, and the automatic-reconnect setting only ever calls it, - // so no configuration can reach this one. + // Reattach AND ask each reattached agent to carry on — what the desktop prompt calls resuming, + // and what an opted-in launch runs without asking. Separate from `restartResume`, which sends + // nothing, but reachable from a setting rather than only from a button. name: 'agentSession.restartContinue', params: RestartResumeParams, handler: async (params, ctx) => { @@ -47,6 +52,9 @@ export const STRUCTURED_AGENT_SESSION_RESTART_RESUME_METHODS = [ } }), defineMethod({ + // Reattach only, no send. No Orca surface calls it now — the desktop prompt's single action is + // resume-and-continue — but it is a PUBLISHED wire method, so dropping it is a wire removal an + // older or non-desktop client would meet as an unknown method. name: 'agentSession.restartResume', params: RestartResumeParams, handler: async (params, ctx) => { diff --git a/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx b/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx index f5f87cf593b..a6dba8394ae 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartAgentRow.tsx @@ -52,7 +52,7 @@ export function ResumeCandidateRow({ className="shrink-0" aria-label={translate( 'auto.components.NativeChatResumeOnRestartModal.selectAgent', - 'Reconnect {{value0}} chat "{{value1}}" in {{value2}}', + 'Resume {{value0}} chat "{{value1}}" in {{value2}}', { value0: agentLabel, value1: title, value2: workspaceName } )} /> diff --git a/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx b/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx index eacd6571a26..2c6ebb1ce6a 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartGroups.tsx @@ -155,7 +155,7 @@ export function ResumeOnRestartGroups({ selected, onToggle }: { - candidates: ResumeCandidate[] + candidates: readonly ResumeCandidate[] listedAt: number busy: boolean selected: ReadonlySet diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx index 9e5cebabd11..977e76fbd79 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.test.tsx @@ -7,7 +7,14 @@ import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { useAppStore } from '../store' import { getDefaultSettings } from '../../../shared/constants' 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 { + _resetNativeChatRestartOffer, + getNativeChatRestartOffer +} from './native-chat-resume-on-restart-store' const rpc = vi.hoisted(() => vi.fn()) vi.mock('@/runtime/structured-agent-session-client', () => ({ @@ -47,8 +54,14 @@ function checkbox(index: number): HTMLElement { return found } +function offerIds(): string[] { + return getNativeChatRestartOffer().candidates.map((candidate) => candidate.sessionId) +} + beforeEach(() => { rpc.mockReset() + _resetNativeChatRestartOffer() + consumeNativeChatResumeOnRestartDialogRequest() vi.mocked(toast).mockClear() useAppStore.setState(useAppStore.getInitialState(), true) useAppStore.setState({ @@ -68,13 +81,11 @@ afterEach(() => { act(() => root.unmount()) container.remove() useAppStore.setState(useAppStore.getInitialState(), true) + _resetNativeChatRestartOffer() + consumeNativeChatResumeOnRestartDialogRequest() }) -it.each([ - ['Reconnect 1', 'agentSession.restartResume'], - ['Reconnect and continue', 'agentSession.restartContinue'], - ['Not now', 'agentSession.restartResumableDismiss'] -])('keeps next-launch preference out of the current %s action', async (label, method) => { +it('keeps next-launch preference out of the current resume action', async () => { const action = Promise.withResolvers() rpc.mockImplementation(async (_target, calledMethod) => { if (calledMethod === 'agentSession.restartResumable') { @@ -85,22 +96,149 @@ it.each([ await act(async () => root.render()) await act(async () => checkbox(1).click()) await act(async () => checkbox(2).click()) - await act(async () => button(label).click()) + await act(async () => button('Resume 1 chat').click()) expect(useAppStore.getState().settings?.nativeChatResumeWorkOnRestart).toBe(true) expect(rpc.mock.calls.map((call) => [call[1], call[2]])).toEqual([ ['agentSession.restartResumable', undefined], - [method, method === 'agentSession.restartResumableDismiss' ? {} : { sessionIds: ['a'] }] + ['agentSession.restartContinue', { sessionIds: ['a'] }] ]) await act(async () => action.resolve({ - results: [{ sessionId: 'a', outcome: 'resumed' }], - continued: [{ sessionId: 'a', outcome: 'continued' }] + resumed: [{ sessionId: 'a', outcome: 'resumed' }], + continued: [{ sessionId: 'a', outcome: 'continued' }], + sessions: [] }) ) expect(rpc).toHaveBeenCalledTimes(2) }) -it('automatically reconnects once when the launch begins opted in', 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()) + // 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([ + 'Dismiss all', + 'Resume 2 chats', + 'Close' + ]) +}) + +// Closing is the only snooze, so it carries the whole of one: saves the preference like every +// 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 act(async () => checkbox(2).click()) + await act(async () => button('Close').click()) + expect(useAppStore.getState().settings?.nativeChatResumeWorkOnRestart).toBe(true) + expect(rpc.mock.calls.map((call) => call[1])).toEqual(['agentSession.restartResumable']) + expect(offerIds()).toEqual(['a', 'b']) + expect(document.querySelector('[role="dialog"]')).toBeNull() +}) + +it('fully dismisses the offer only through Dismiss all', async () => { + rpc.mockImplementation(async (_target, method) => + method === 'agentSession.restartResumable' + ? { sessions: offered } + : { dismissed: 2, sessions: [] } + ) + await act(async () => root.render()) + await act(async () => button('Dismiss all').click()) + expect(rpc.mock.calls.map((call) => [call[1], call[2]])).toEqual([ + ['agentSession.restartResumable', undefined], + ['agentSession.restartResumableDismiss', {}] + ]) + expect(offerIds()).toEqual([]) +}) + +// Bookkeeping must never gate the user's own action: the dismissal lands in the UI either way, and +// a write Orca could not confirm is reported instead of trapping the dialog open. +it('reports a dismissal the host never confirmed instead of trapping the dialog', async () => { + rpc.mockImplementation(async (_target, method) => { + if (method === 'agentSession.restartResumable') { + return { sessions: offered } + } + throw new Error('response lost') + }) + await act(async () => root.render()) + await act(async () => button('Dismiss all').click()) + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(toast).toHaveBeenCalledWith(expect.stringContaining('was not confirmed')) + // The host still holds the markers, so the status entry must keep saying so. + expect(offerIds()).toEqual(['a', 'b']) +}) + +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 act(async () => checkbox(2).click()) + await act(async () => button('Dismiss all').click()) + expect(useAppStore.getState().settings?.nativeChatResumeWorkOnRestart).toBe(true) +}) + +// Reopening must ask the host again, never replay the launch answer: the chats already resumed are +// gone from its list, and offering them back earns the user a refusal. +it('never re-offers a resumed chat when the status entry reopens the dialog', async () => { + let remaining = offered + rpc.mockImplementation(async (_target, method) => { + if (method === 'agentSession.restartResumable') { + return { sessions: remaining } + } + // The host spends the claim it settled, so its next answer no longer names that chat. + remaining = remaining.filter((candidate) => candidate.sessionId !== 'a') + return { + resumed: [{ sessionId: 'a', outcome: 'resumed' }], + continued: [{ sessionId: 'a', outcome: 'continued' }], + sessions: remaining + } + }) + await act(async () => + root.render( + + + + + ) + ) + await act(async () => checkbox(1).click()) + await act(async () => button('Resume 1 chat').click()) + expect(offerIds()).toEqual(['b']) + // The action closes the dialog itself; the status entry is the way back to what is left. + expect(document.querySelector('[role="dialog"]')).toBeNull() + + await act(async () => button('1 chat to resume').click()) + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + expect(offerIds()).toEqual(['b']) + // One offered row plus the preference box — never the resumed chat again. + expect(document.querySelectorAll('[role="checkbox"]')).toHaveLength(2) +}) + +// Resuming spends the host's claims, so the offer has to shrink with it. A count left standing over +// chats the host already handed back sends the user to a status entry that re-reads, finds nothing, +// and does nothing. +it('settles the offer for the chats a resume reattached', async () => { + rpc.mockImplementation(async (_target, method) => + method === 'agentSession.restartResumable' + ? { sessions: offered } + : { + resumed: [{ sessionId: 'a', outcome: 'resumed' }], + continued: [{ sessionId: 'a', outcome: 'continued' }], + sessions: [offered[1]!] + } + ) + await act(async () => root.render()) + await act(async () => checkbox(1).click()) + await act(async () => button('Resume 1 chat').click()) + expect(offerIds()).toEqual(['b']) +}) + +// The point of the preference. "Resume automatically" has to run the action the button runs — +// reattach AND ask each agent to carry on — or it recovers nothing that opening the chat would not. +it('resumes and continues once when the launch begins opted in', async () => { useAppStore.setState({ settings: { ...getDefaultSettings(''), @@ -109,7 +247,13 @@ it('automatically reconnects once when the launch begins opted in', async () => } }) rpc.mockImplementation(async (_target, method) => - method === 'agentSession.restartResumable' ? { sessions: offered } : { results: [] } + method === 'agentSession.restartResumable' + ? { sessions: offered } + : { + resumed: offered.map(({ sessionId }) => ({ sessionId, outcome: 'resumed' })), + continued: offered.map(({ sessionId }) => ({ sessionId, outcome: 'continued' })), + sessions: [] + } ) await act(async () => root.render( @@ -132,22 +276,56 @@ it('automatically reconnects once when the launch begins opted in', async () => ) expect(rpc.mock.calls.map((call) => [call[1], call[2]])).toEqual([ ['agentSession.restartResumable', undefined], - ['agentSession.restartResume', {}] + ['agentSession.restartContinue', {}] ]) + // Automatic is never silent, and the offer shrinks by what the host says it reattached. + expect(toast).toHaveBeenCalledWith('Resumed 2 chats and asked them to continue') + expect(offerIds()).toEqual([]) + expect(document.querySelector('[role="dialog"]')).toBeNull() +}) + +// An opted-in launch reports the chats the host would not take, exactly as the button does. +it('reports refused and newly ineligible chats on an opted-in launch', async () => { + useAppStore.setState({ + settings: { + ...getDefaultSettings(''), + experimentalStructuredNativeChat: true, + nativeChatResumeWorkOnRestart: true + } + }) + rpc.mockImplementation(async (_target, method) => + method === 'agentSession.restartResumable' + ? { sessions: offered } + : { + resumed: [], + continued: [{ sessionId: 'a', outcome: 'refused' }], + sessions: offered + } + ) + await act(async () => root.render()) + expect(toast).toHaveBeenCalledWith( + '2 chats could not be continued. Open them to continue manually.' + ) }) it('dispatches the selected action while a future preference save is still pending', async () => { const saved = Promise.withResolvers() useAppStore.setState({ updateSettings: () => saved.promise }) rpc.mockImplementation(async (_target, method) => - method === 'agentSession.restartResumable' ? { sessions: offered } : { results: [] } + method === 'agentSession.restartResumable' + ? { sessions: offered } + : { + resumed: [{ sessionId: 'a', outcome: 'resumed' }], + continued: [{ sessionId: 'a', outcome: 'continued' }], + sessions: [] + } ) await act(async () => root.render()) await act(async () => checkbox(1).click()) await act(async () => checkbox(2).click()) - await act(async () => button('Reconnect 1').click()) + await act(async () => button('Resume 1 chat').click()) expect(rpc.mock.calls.at(-1)?.slice(1)).toEqual([ - 'agentSession.restartResume', + 'agentSession.restartContinue', { sessionIds: ['a'] } ]) await act(async () => saved.reject(new Error('settings write failed'))) @@ -162,11 +340,12 @@ it.each(['pending', 'unknown', 'refused', 'missing'])( ? { sessions: offered } : { continued: - outcome === 'missing' ? [] : offered.map(({ sessionId }) => ({ sessionId, outcome })) + outcome === 'missing' ? [] : offered.map(({ sessionId }) => ({ sessionId, outcome })), + sessions: offered } ) await act(async () => root.render()) - await act(async () => button('Reconnect and continue').click()) + await act(async () => button('Resume 2 chats').click()) const notices = vi .mocked(toast) .mock.calls.map(([text]) => text) @@ -179,33 +358,37 @@ it.each(['pending', 'unknown', 'refused', 'missing'])( } ) -it('reports refused and newly ineligible reconnects', async () => { +// The response is not validated, so a payload this side cannot read is treated like a lost one: the +// message may well have gone out, and the offer must not shrink over chats nothing confirmed. +it('reports an unreadable resume response as an unconfirmed delivery', async () => { rpc.mockImplementation(async (_target, method) => - method === 'agentSession.restartResumable' - ? { sessions: offered } - : { results: [{ sessionId: 'a', outcome: 'refused' }] } + method === 'agentSession.restartResumable' ? { sessions: offered } : { sessions: offered } ) await act(async () => root.render()) - await act(async () => button('Reconnect all').click()) - expect(toast).toHaveBeenCalledWith(expect.stringContaining('2 chats could not be reconnected')) + await act(async () => button('Resume 2 chats').click()) + expect(toast).toHaveBeenCalledWith(expect.stringContaining('unconfirmed')) + expect(offerIds()).toEqual(['a', 'b']) + expect(document.querySelector('[role="dialog"]')).toBeNull() }) -it.each(['Reconnect all', 'Reconnect and continue'])( - 'reports a lost %s response without retrying the action', - async (label) => { - rpc.mockImplementation(async (_target, method) => { - if (method === 'agentSession.restartResumable') { - return { sessions: offered } - } - throw new Error('response lost') - }) - await act(async () => root.render()) - await act(async () => button(label).click()) - expect(toast).toHaveBeenCalledWith(expect.stringContaining('unconfirmed')) - expect(rpc).toHaveBeenCalledTimes(2) - expect(document.querySelector('[role="dialog"]')).toBeNull() - } -) +it('reports a lost resume response without retrying the action', async () => { + rpc.mockImplementation(async (_target, method) => { + if (method === 'agentSession.restartResumable') { + return { sessions: offered } + } + throw new Error('response lost') + }) + await act(async () => root.render()) + 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. + expect(rpc.mock.calls.map((call) => [call[1], call[2]])).toEqual([ + ['agentSession.restartResumable', undefined], + ['agentSession.restartContinue', { sessionIds: ['a', 'b'] }], + ['agentSession.restartResumable', undefined] + ]) + expect(document.querySelector('[role="dialog"]')).toBeNull() +}) it('keeps an unconfirmed delivery visible when another chat was refused', async () => { rpc.mockImplementation(async (_target, method) => @@ -215,11 +398,12 @@ it('keeps an unconfirmed delivery visible when another chat was refused', async continued: [ { sessionId: 'a', outcome: 'unknown' }, { sessionId: 'b', outcome: 'refused' } - ] + ], + sessions: offered } ) await act(async () => root.render()) - await act(async () => button('Reconnect and continue').click()) + 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.' diff --git a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx index 4c8286bed4e..cdddb5aeea1 100644 --- a/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx +++ b/src/renderer/src/components/NativeChatResumeOnRestartModal.tsx @@ -1,5 +1,5 @@ -import { useCallback, useEffect, useRef, useState } from 'react' -import { Info, RotateCcw } from 'lucide-react' +import { useCallback, useMemo, useState, useSyncExternalStore } from 'react' +import { RotateCcw } from 'lucide-react' import { Button } from './ui/button' import { Checkbox } from './ui/checkbox' import { @@ -11,172 +11,75 @@ import { DialogTitle } from './ui/dialog' import { useAppStore } from '../store' -import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { translate } from '@/i18n/i18n' -import { Popover, PopoverContent, PopoverTrigger } from './ui/popover' -import { AGENT_SESSION_RESTART_CONTINUATION_MESSAGE } from '../../../shared/agent-session-restart-continuation' import { ResumeOnRestartGroups } from './NativeChatResumeOnRestartGroups' import { - announceRestartResults, - announceRestartUnconfirmed, - type RestartActionOutcome -} from './native-chat-restart-action-notifications' + consumeNativeChatResumeOnRestartDialogRequest, + getNativeChatResumeOnRestartDialogRequest, + subscribeNativeChatResumeOnRestartDialog +} from './native-chat-resume-on-restart-dialog' import { - allResumeSessionIds, - selectedResumeSessionIds, - type ResumeCandidate -} from './native-chat-resume-on-restart-grouping' + continueNativeChatRestartOffer, + dismissNativeChatRestartOffer, + useNativeChatRestartOffer +} from './native-chat-resume-on-restart-store' /** - * What would be reconnected, shown before anything runs. + * What would be resumed, shown before anything runs. * - * The list is the point. Reconnecting a chat that was not working starts a provider the user never - * asked for and puts a misleading row in front of them, so they see exactly which chats the last - * teardown recorded as mid-turn and decide. The checkbox is the opt-in to skipping this prompt in - * future — it removes the PROMPT, never a safety check: automatic mode calls the same RPC, which - * re-derives the same predicate and staggers the same way. + * Resuming reattaches a chat AND asks the agent to carry on, so the list is the point: the user + * sees which chats the last teardown recorded as mid-turn before a message goes anywhere. Every + * string here has to say that a message is sent and that the user's own prompt is not re-sent. * - * Reconnecting restores the session at the point it stopped; it does NOT continue the interrupted - * reply — that was measured. Every user-facing string here has to keep saying so. + * 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. * - * Turning the offer down spends the markers. A prompt that returns at every launch is worse than - * the problem it solves, and nothing is lost: opening a chat takes a resume-capable hold, which - * re-acquires the provider at the same cursor. + * Closing is a SNOOZE, so looking around before deciding cannot remove the recovery. Dismiss all is + * the explicit path that deletes the durable records. */ -// Structured sessions run on the machine hosting the runtime; both launch resolvers refuse anything -// else, so there is no remote target to aim this at. -const LOCAL = { kind: 'local' } as const - -/** Shows the LITERAL message, read from the same constant the host sends, so the popover cannot - * drift into describing something other than what goes out. */ -function ContinuationExplainer(): React.JSX.Element { - return ( - - - - - -
-

- {translate( - 'auto.components.NativeChatResumeOnRestartModal.whatIsSentTitle', - 'What Orca sends' - )} -

-

- {translate( - 'auto.components.NativeChatResumeOnRestartModal.whatIsSentBody', - 'Continuing sends one short message to each agent, telling it that Orca restarted and asking it to check its last action before carrying on. Your own prompt is never re-sent.' - )} -

-
- {AGENT_SESSION_RESTART_CONTINUATION_MESSAGE} -
-
-
-
- ) -} - export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { const structuredEnabled = useAppStore( (store) => store.settings?.experimentalStructuredNativeChat === true ) - const launchOffer = useRef | null>(null) + const { candidates, listedAt } = useNativeChatRestartOffer(structuredEnabled) + // 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( + subscribeNativeChatResumeOnRestartDialog, + getNativeChatResumeOnRestartDialogRequest, + getNativeChatResumeOnRestartDialogRequest + ) const updateSettings = useAppStore((store) => store.updateSettings) - const [candidates, setCandidates] = useState([]) - /** Clock 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. */ - const [listedAt, setListedAt] = useState(0) const [dontAskAgain, setDontAskAgain] = useState(false) const [busy, setBusy] = useState(false) - const [resolved, setResolved] = useState(false) - /** - * Which of the OFFERED chats to act on. Defaults to all, and is only ever narrowed by the user. - * - * This changes which eligible chats are acted on, never what is eligible: ids are seeded from the - * host's own answer, `selectedResumeSessionIds` intersects back against it before any call, and - * the host re-derives the predicate regardless of what is sent. - */ - const [selected, setSelected] = useState>(() => new Set()) + /** 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. */ + const chosen = useMemo( + () => + candidates + .map((candidate) => candidate.sessionId) + .filter((sessionId) => !excluded.has(sessionId)), + [candidates, excluded] + ) + const selected = useMemo(() => new Set(chosen), [chosen]) const toggleSelected = useCallback((sessionId: string, checked: boolean) => { - setSelected((current) => { + setExcluded((current) => { const next = new Set(current) if (checked) { - next.add(sessionId) - } else { next.delete(sessionId) + } else { + next.add(sessionId) } return next }) }, []) - useEffect(() => { - if (!structuredEnabled || resolved) { - return - } - let cancelled = false - // Fetched after mount, never awaited by startup: the workspace is usable first. - const loadOffer = async (): Promise => { - // The preference belongs to this launch's request; later saves cannot dispatch another. - const autoResume = useAppStore.getState().settings?.nativeChatResumeWorkOnRestart === true - try { - const offered = await callStructuredAgentSession<{ sessions: ResumeCandidate[] }>( - LOCAL, - 'agentSession.restartResumable' - ) - if (offered.sessions.length === 0) { - return [] - } - if (autoResume) { - // Identical call to the buttons below; the host re-derives eligibility either way. - const result = await callStructuredAgentSession<{ results: RestartActionOutcome[] }>( - LOCAL, - 'agentSession.restartResume', - {} - ).catch(() => { - announceRestartUnconfirmed(offered.sessions.length, 'reconnect') - return null - }) - if (!result) { - return [] - } - // Automatic must never be silent: someone who ticked the box months ago still sees this. - announceRestartResults(allResumeSessionIds(offered.sessions), result.results, 'reconnect') - return [] - } - return offered.sessions - } catch { - // A host that cannot answer offers nothing. There is no failure worth a modal of its own. - return [] - } - } - launchOffer.current ??= loadOffer() - void launchOffer.current.then((offered) => { - if (!cancelled) { - setListedAt(Date.now()) - setCandidates(offered) - setSelected(new Set(allResumeSessionIds(offered))) - } - }) - return () => { - cancelled = true - } - }, [resolved, structuredEnabled]) - - /** Applied on whichever action the user takes, so the box means the same thing either way. */ + /** Applied on whichever action the user takes, so the box means the same thing every way out. */ const persistPreference = useCallback(async (): Promise => { if (dontAskAgain) { await updateSettings({ nativeChatResumeWorkOnRestart: true }).catch(() => undefined) @@ -184,96 +87,50 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { }, [dontAskAgain, updateSettings]) const resume = useCallback( - async (sessionIds?: string[]): Promise => { - setBusy(true) - try { - void persistPreference() - const result = await callStructuredAgentSession<{ results: RestartActionOutcome[] }>( - LOCAL, - 'agentSession.restartResume', - sessionIds ? { sessionIds } : {} - ) - const settled = new Set(result.results.map((entry) => entry.sessionId)) - const remaining = candidates.filter((candidate) => !settled.has(candidate.sessionId)) - announceRestartResults( - sessionIds ?? allResumeSessionIds(candidates), - result.results, - 'reconnect' - ) - setCandidates(remaining) - // An empty result means the host settled none of them — never leave the dialog sitting open - // behind a button that did nothing. - if (remaining.length === 0 || result.results.length === 0) { - setResolved(true) - } - } catch { - announceRestartUnconfirmed( - (sessionIds ?? allResumeSessionIds(candidates)).length, - 'reconnect' - ) - setResolved(true) - } finally { - setBusy(false) - } - }, - [candidates, persistPreference] - ) - - /** - * Reconnect AND ask each agent to carry on. A deliberate action only. - * - * The automatic path calls `restartResume`, which has no send in it, so no setting — the - * checkbox included — can reach this. The checkbox opts into automatic RECONNECTION, never - * automatic continuation. - */ - const reconnectAndContinue = useCallback( async (sessionIds: string[]): Promise => { setBusy(true) try { void persistPreference() - const result = await callStructuredAgentSession<{ - continued: RestartActionOutcome[] - }>(LOCAL, 'agentSession.restartContinue', { sessionIds }) - announceRestartResults(sessionIds, result.continued, 'continue') - setResolved(true) - } catch { - announceRestartUnconfirmed(sessionIds.length, 'continue') - setResolved(true) + await continueNativeChatRestartOffer(sessionIds) } finally { setBusy(false) + consumeNativeChatResumeOnRestartDialogRequest() } }, [persistPreference] ) - /** Any close is a decline, and a decline spends the markers so this cannot return every launch. */ - const decline = useCallback(async (): Promise => { - setResolved(true) + /** Closing is a snooze: the host keeps the offer and the status bar keeps the way back to it. */ + const snooze = useCallback((): void => { + consumeNativeChatResumeOnRestartDialogRequest() void persistPreference() - await callStructuredAgentSession(LOCAL, 'agentSession.restartResumableDismiss', {}).catch( - () => undefined - ) }, [persistPreference]) - if (!structuredEnabled || resolved || candidates.length === 0) { + const dismissAll = useCallback(async (): Promise => { + void persistPreference() + // Bookkeeping never gates the user's own action: the dialog closes here whatever the host + // answers, rather than being trapped open behind a rejected promise. + consumeNativeChatResumeOnRestartDialogRequest() + await dismissNativeChatRestartOffer() + }, [persistPreference]) + + if (!structuredEnabled || !open || candidates.length === 0) { return null } const interruptedByUpdate = candidates.some((candidate) => candidate.trigger === 'update') - // Intersected against what the host offered, so an action can never name a chat it did not. - const chosen = selectedResumeSessionIds(candidates, selected) return ( { if (!next && !busy) { - void decline() + snooze() } }} > - {/* Height is capped, never the data: seeing WHICH chats would be reconnected is the whole - point, so the list scrolls inside the dialog while the header and primary action stay. */} + {/* Height is capped, never the data: the list scrolls inside the dialog so the header and + the primary action stay put however many chats were interrupted. */} @@ -282,7 +139,7 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { {translate( 'auto.components.NativeChatResumeOnRestartModal.title', - 'Reconnect interrupted chats?' + 'Resume interrupted chats?' )} @@ -290,29 +147,20 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { {interruptedByUpdate ? translate( 'auto.components.NativeChatResumeOnRestartModal.updateBody', - 'These chats were mid-turn when Orca installed an update. Reconnecting restores each one where it stopped, with its full context and without re-sending your prompt — the interrupted reply will not continue on its own.' + 'These chats were mid-turn when Orca installed an update. Resuming restores each one where it stopped, with its full context, and asks the agent to check its last action before carrying on. Your own prompt is not re-sent.' ) : translate( 'auto.components.NativeChatResumeOnRestartModal.body', - 'These chats were mid-turn when Orca closed. Reconnecting restores each one where it stopped, with its full context and without re-sending your prompt — the interrupted reply will not continue on its own.' + 'These chats were mid-turn when Orca closed. Resuming restores each one where it stopped, with its full context, and asks the agent to check its last action before carrying on. Your own prompt is not re-sent.' )} - {/* The true state of things is counterintuitive — the terminal sessions survived and the - chats did not — so say so where it frames the list, not as a footnote. "kept running" - rather than "were restored": nothing reconnected them, they never stopped. */} -

- {translate( - 'auto.components.NativeChatResumeOnRestartModal.terminalSessionsUnaffected', - 'Only chats are affected — your terminal sessions kept running and need nothing from you.' - )} -

@@ -325,15 +173,6 @@ export function NativeChatResumeOnRestartModal(): React.JSX.Element | null { />
- {/* Says the quiet part: declining is not destructive, because opening the chat still - re-acquires it at the same cursor. */} -

- {translate( - 'auto.components.NativeChatResumeOnRestartModal.notNowHint', - 'Not now keeps everything — you can reopen any chat later and carry on from the same point.' - )} -

- + {/* Two controls: one deletes the offer, one acts on it. Closing snoozes, so it needs none. */} - - - - {/* Secondary, never the default: continuing sends a message, reconnecting does not. */} - - - + : translate( + 'auto.components.NativeChatResumeOnRestartModal.resumeSelected', + 'Resume {{value0}} chats', + { value0: chosen.length } + )} +
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 77245fe07b3..f794fcc5569 100644 --- a/src/renderer/src/components/native-chat-restart-action-notifications.ts +++ b/src/renderer/src/components/native-chat-restart-action-notifications.ts @@ -1,26 +1,17 @@ import { toast } from 'sonner' import { translate } from '@/i18n/i18n' -export type RestartActionOutcome = { - sessionId: string - outcome: 'resumed' | 'continued' | 'pending' | 'unknown' | 'refused' -} +/** + * What Orca tells the user after acting on a restart offer. + * + * Resuming sends a message, so every string here has to say one went out — and an opted-in launch + * has no dialog in front of it, which makes these toasts the only place that user learns it did. + */ -function announceResumed(count: number): void { - if (count <= 0) { - return - } - toast( - count === 1 - ? translate('auto.components.NativeChatResumeOnRestartModal.resumedOne', 'Reconnected 1 chat') - : translate( - 'auto.components.NativeChatResumeOnRestartModal.resumedMany', - 'Reconnected {{value0}} chats', - { - value0: count - } - ) - ) +/** One `continued` row as the host reports it. */ +export type RestartContinuationOutcome = { + sessionId: string + outcome: 'continued' | 'pending' | 'unknown' | 'refused' } function announceContinued(count: number): void { @@ -31,39 +22,43 @@ function announceContinued(count: number): void { count === 1 ? translate( 'auto.components.NativeChatResumeOnRestartModal.continuedOne', - 'Reconnected 1 chat and asked it to continue' + 'Resumed 1 chat and asked it to continue' ) : translate( 'auto.components.NativeChatResumeOnRestartModal.continuedMany', - 'Reconnected {{value0}} chats and asked them to continue', + 'Resumed {{value0}} chats and asked them to continue', { value0: count } ) ) } -export function announceRestartUnconfirmed(count: number, action: 'reconnect' | 'continue'): void { +/** Delivery the host never confirmed. Reported, never retried — a second send is the user's call. */ +export function announceRestartUnconfirmed(count: number): void { if (count <= 0) { return } toast( - action === 'continue' - ? translate( - 'auto.components.NativeChatResumeOnRestartModal.continueUnconfirmed', - 'Continuation delivery is unconfirmed for {{value0}} chats. Open them to check before sending another message.', - { value0: count, count } - ) - : translate( - 'auto.components.NativeChatResumeOnRestartModal.reconnectUnconfirmed', - 'Reconnection is unconfirmed for {{value0}} chats. You can still open them normally.', - { value0: count, count } - ) + translate( + 'auto.components.NativeChatResumeOnRestartModal.continueUnconfirmed', + 'Continuation delivery is unconfirmed for {{value0}} chats. Open them to check before sending another message.', + { value0: count, count } + ) + ) +} + +/** A dismissal Orca could not confirm. The offer belongs to the host, so say it may still be there. */ +export function announceRestartDismissUnconfirmed(): void { + toast( + translate( + 'auto.components.NativeChatResumeOnRestartModal.dismissUnconfirmed', + 'Dismissing the resume offer was not confirmed — it may still be in the status bar.' + ) ) } export function announceRestartResults( requested: readonly string[], - results: readonly RestartActionOutcome[], - action: 'reconnect' | 'continue' + results: readonly RestartContinuationOutcome[] ): void { const bySession = new Map(results.map((result) => [result.sessionId, result.outcome])) let succeeded = 0 @@ -71,7 +66,7 @@ export function announceRestartResults( let refused = 0 for (const sessionId of new Set(requested)) { const outcome = bySession.get(sessionId) - if (outcome === (action === 'continue' ? 'continued' : 'resumed')) { + if (outcome === 'continued') { succeeded += 1 } else if (outcome === 'pending' || outcome === 'unknown') { unconfirmed += 1 @@ -80,25 +75,15 @@ export function announceRestartResults( refused += 1 } } - if (action === 'continue') { - announceContinued(succeeded) - } else { - announceResumed(succeeded) - } + announceContinued(succeeded) if (refused > 0) { toast( - action === 'continue' - ? translate( - 'auto.components.NativeChatResumeOnRestartModal.continueRefused', - '{{value0}} chats could not be continued. Open them to continue manually.', - { value0: refused, count: refused } - ) - : translate( - 'auto.components.NativeChatResumeOnRestartModal.reconnectRefused', - '{{value0}} chats could not be reconnected. You can still open them normally.', - { value0: refused, count: refused } - ) + translate( + 'auto.components.NativeChatResumeOnRestartModal.continueRefused', + '{{value0}} chats could not be continued. Open them to continue manually.', + { value0: refused, count: refused } + ) ) } - announceRestartUnconfirmed(unconfirmed, action) + announceRestartUnconfirmed(unconfirmed) } diff --git a/src/renderer/src/components/native-chat-resume-on-restart-dialog.ts b/src/renderer/src/components/native-chat-resume-on-restart-dialog.ts new file mode 100644 index 00000000000..4b139d1c2a3 --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-on-restart-dialog.ts @@ -0,0 +1,34 @@ +let pendingOpen = false +const listeners = new Set<() => void>() + +function notify(): void { + for (const listener of listeners) { + listener() + } +} + +// Why: the launch load and the status-bar entry both open this dialog, and either can fire before +// it subscribes. Keeping the request as an external snapshot prevents mount ordering from losing it. +export function requestNativeChatResumeOnRestartDialog(): void { + pendingOpen = true + notify() +} + +export function consumeNativeChatResumeOnRestartDialogRequest(): void { + if (!pendingOpen) { + return + } + pendingOpen = false + notify() +} + +export function getNativeChatResumeOnRestartDialogRequest(): boolean { + return pendingOpen +} + +export function subscribeNativeChatResumeOnRestartDialog(listener: () => void): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} diff --git a/src/renderer/src/components/native-chat-resume-on-restart-grouping.test.ts b/src/renderer/src/components/native-chat-resume-on-restart-grouping.test.ts index 42af9b3ca7e..9f8bcb17dcb 100644 --- a/src/renderer/src/components/native-chat-resume-on-restart-grouping.test.ts +++ b/src/renderer/src/components/native-chat-resume-on-restart-grouping.test.ts @@ -1,7 +1,4 @@ -// Arranging the offer, and the one rule selection must never break. -// -// Checking a box changes WHICH eligible chats are acted on. It can never change what is eligible, -// and it can never introduce a chat the host did not offer. +// Arranging the offer for display: which ids it covers, and how the rows nest. import { describe, expect, it } from 'vitest' import { @@ -10,7 +7,6 @@ import { groupResumeWorkspacesByRepo, resolveResumeGroupHeader, resumeWorkspaceKind, - selectedResumeSessionIds, type ResumeCandidate } from './native-chat-resume-on-restart-grouping' @@ -30,32 +26,12 @@ function candidate(overrides: Partial = {}): ResumeCandidate { } } -describe('selecting which offered chats to act on', () => { - it('defaults to every chat the host offered', () => { +describe('naming every offered chat', () => { + it('keeps the order the host offered', () => { const offered = [candidate(), candidate({ sessionId: 'session-2' })] expect(allResumeSessionIds(offered)).toEqual(['session-1', 'session-2']) }) - - it('acts only on the chats that are checked', () => { - const offered = [candidate(), candidate({ sessionId: 'session-2' })] - - expect(selectedResumeSessionIds(offered, new Set(['session-2']))).toEqual(['session-2']) - }) - - // THE SAFETY RULE. A selection is intersected against the offer, so a stale or invented id cannot - // reach an action. The host re-derives the predicate regardless; this keeps the client honest too. - it('drops any selected id the host did not offer', () => { - const offered = [candidate()] - - expect( - selectedResumeSessionIds(offered, new Set(['session-1', 'session-never-offered'])) - ).toEqual(['session-1']) - }) - - it('acts on nothing when nothing is checked', () => { - expect(selectedResumeSessionIds([candidate()], new Set())).toEqual([]) - }) }) describe('arranging the offer the way the sidebar does', () => { 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 7a6c8080d88..a503f440d92 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 @@ -40,7 +40,7 @@ export type ResumeRepoGroup = { * The same id space automation dispatch resolves: a folder workspace by its full `folder:` * key, a git worktree by its bare `repoId::path` id. */ -export function isFolderWorkspaceId(workspaceId: string): boolean { +function isFolderWorkspaceId(workspaceId: string): boolean { return parseWorkspaceKey(workspaceId)?.type === 'folder' } @@ -127,22 +127,7 @@ export function resolveResumeGroupHeader( } } -/** Every offered session id, which is the default selection and the ceiling on any selection. */ +/** Every offered session id, which is what an unselective action names. */ export function allResumeSessionIds(candidates: readonly ResumeCandidate[]): string[] { return candidates.map((candidate) => candidate.sessionId) } - -/** - * Narrows a selection to sessions the host actually offered. - * - * Selection changes only WHICH eligible chats are acted on, never what is eligible, so anything not - * in the offered set is dropped here before it can reach an action. - */ -export function selectedResumeSessionIds( - candidates: readonly ResumeCandidate[], - selected: ReadonlySet -): string[] { - return candidates - .filter((candidate) => selected.has(candidate.sessionId)) - .map((candidate) => candidate.sessionId) -} 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 new file mode 100644 index 00000000000..f1416b4900a --- /dev/null +++ b/src/renderer/src/components/native-chat-resume-on-restart-store.ts @@ -0,0 +1,210 @@ +import { useEffect, useSyncExternalStore } from 'react' +import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { useAppStore } from '../store' +import { + announceRestartDismissUnconfirmed, + announceRestartResults, + announceRestartUnconfirmed, + type RestartContinuationOutcome +} from './native-chat-restart-action-notifications' +import { allResumeSessionIds, type ResumeCandidate } from './native-chat-resume-on-restart-grouping' +import { requestNativeChatResumeOnRestartDialog } from './native-chat-resume-on-restart-dialog' + +/** + * Which interrupted chats the host is still offering to resume, and every action that moves that. + * + * The offer is the HOST's answer, shared by the dialog and the status bar rather than held by + * whichever rendered first. Opening a chat is intentionally read-only; only an explicit action + * changes the durable offer. + * + * What stays on this side is the user's own facts: the snooze, and the preference that decides + * whether the launch asks at all. + */ + +// Structured sessions run on the machine hosting the runtime; both launch resolvers refuse anything +// else, so there is no remote target to aim this at. +const LOCAL = { kind: 'local' } as const + +export type NativeChatRestartOffer = Readonly<{ + candidates: readonly ResumeCandidate[] + /** 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 } +let offer: NativeChatRestartOffer = EMPTY +let launch: Promise | undefined +const listeners = new Set<() => void>() +const LAUNCH_READ_RETRY_DELAYS_MS = [100, 250, 500] as const + +/** The snapshot object is replaced HERE and nowhere else — never during a render — so every + * `useSyncExternalStore` reader sees the same reference until a host answer or a user action + * actually moves the offer. */ +function publish(next: NativeChatRestartOffer): void { + offer = next + for (const listener of listeners) { + listener() + } +} + +export function getNativeChatRestartOffer(): NativeChatRestartOffer { + return offer +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener) + return () => { + listeners.delete(listener) + } +} + +/** + * Re-reads the host's answer. + * + * Called before the dialog is reopened, so a count can never name a chat the host would now refuse. + */ +type HostOfferRead = { + candidates: readonly ResumeCandidate[] + available: boolean +} + +async function readNativeChatRestartOffer(): Promise { + try { + const offered = await callStructuredAgentSession<{ sessions: ResumeCandidate[] }>( + 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 } + } 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 } + } +} + +export async function refreshNativeChatRestartOffer(): Promise { + return (await readNativeChatRestartOffer()).candidates +} + +/** + * Reattach the offered chats, ask each agent to carry on, then replace the offer with the host's + * authoritative remaining list. This keeps the modal and status bar synchronized after every + * action, even when the dialog's snapshot became stale while it was open. + * + * `sessionIds` is the dialog's selection. An opted-in launch names nothing, so the host acts on + * whatever it still offers rather than on a list this side captured a moment earlier, and passes + * `reported` instead: the chats the user was shown, which is what the toasts count. + * + * Never rejects. The payload is unvalidated, and a shape this side did not expect is reported as + * an unconfirmed delivery — the message may well have gone out. + */ +export async function continueNativeChatRestartOffer( + sessionIds: readonly string[] | undefined, + reported: readonly string[] = sessionIds ?? [] +): Promise { + 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) + if (Array.isArray(result.sessions)) { + publish({ candidates: result.sessions, listedAt: Date.now() }) + } else { + await refreshNativeChatRestartOffer() + } + } catch { + await refreshNativeChatRestartOffer() + announceRestartUnconfirmed(reported.length) + } +} + +/** + * Turning the offer down for good, which explicitly deletes the pending durable records. + * + * 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 { + try { + const result = await callStructuredAgentSession<{ sessions?: ResumeCandidate[] }>( + LOCAL, + 'agentSession.restartResumableDismiss', + {} + ) + if (Array.isArray(result.sessions)) { + publish({ candidates: result.sessions, listedAt: Date.now() }) + } else { + await refreshNativeChatRestartOffer() + } + } catch { + await refreshNativeChatRestartOffer() + announceRestartDismissUnconfirmed() + } +} + +/** + * This launch's single read of the offer, and the one decision the preference makes: ask, or + * resume without asking. + * + * "Resume automatically" runs the identical call the button runs — reattach AND ask each agent to + * carry on. Opening a chat remains a separate, read-only inspection action. + * + * Runs once however many surfaces mount, so the count and the dialog describe the same answer and + * an opted-in launch cannot dispatch twice. + */ +async function loadLaunchOffer(): Promise { + // The preference belongs to this launch's request; later saves cannot dispatch another. + const autoResume = useAppStore.getState().settings?.nativeChatResumeWorkOnRestart === true + let read = await readNativeChatRestartOffer() + // Host startup can race the renderer. Retry only failed reads, never a confirmed empty result, + // so a transient startup gap does not strand a durable offer or add steady-state polling. + for (const delay of LAUNCH_READ_RETRY_DELAYS_MS) { + if (read.available) { + break + } + await new Promise((resolve) => setTimeout(resolve, delay)) + read = await readNativeChatRestartOffer() + } + const offered = read.candidates + if (offered.length === 0) { + return + } + if (!autoResume) { + requestNativeChatResumeOnRestartDialog() + return + } + await continueNativeChatRestartOffer(undefined, allResumeSessionIds(offered)) +} + +/** + * The offer, fetching it on first use. + * + * `enabled` is a gate, not a trigger: settings arrive after the first render, so the fetch waits + * for the flag rather than being lost when it was still undefined. + */ +export function useNativeChatRestartOffer(enabled: boolean): NativeChatRestartOffer { + useEffect(() => { + if (enabled) { + // Fetched after mount, never awaited by startup: the workspace is usable first. + launch ??= loadLaunchOffer() + } + }, [enabled]) + return useSyncExternalStore(subscribe, getNativeChatRestartOffer, getNativeChatRestartOffer) +} + +/** @internal - tests need a clean module between cases. */ +export function _resetNativeChatRestartOffer(): void { + offer = EMPTY + launch = undefined + listeners.clear() +} diff --git a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx index 20373467516..c02b53e268f 100644 --- a/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx +++ b/src/renderer/src/components/settings/NativeChatExperimentalSetting.tsx @@ -159,13 +159,13 @@ export function NativeChatExperimentalSetting({

{translate( 'auto.components.settings.ExperimentalPane.nativeChat.resumeCopy', - 'When Orca quits or installs an update, chats that were mid-turn are offered again on the next launch. On, they are reconnected without asking and Orca tells you afterwards — the same thing as ticking "Don\'t ask again" in that prompt. Off, you choose from the list each time. Reconnecting restores a chat where it stopped; it does not continue the interrupted reply.' + 'When Orca quits or installs an update, chats that were mid-turn are automatically resumed when Orca is reopened.' )}

@@ -173,7 +173,7 @@ export function NativeChatExperimentalSetting({ checked={resumeOnRestartEnabled} ariaLabel={translate( 'auto.components.settings.ExperimentalPane.nativeChat.resumeToggleLabel', - 'Toggle automatic reconnect after a restart' + 'Toggle automatic resume after a restart' )} onChange={() => updateSettings({ nativeChatResumeWorkOnRestart: !resumeOnRestartEnabled }) diff --git a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx new file mode 100644 index 00000000000..4c034a2f0d6 --- /dev/null +++ b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.test.tsx @@ -0,0 +1,147 @@ +// @vitest-environment happy-dom + +import { act, cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultSettings } from '../../../../shared/constants' +import { useAppStore } from '../../store' +import { TooltipProvider } from '../ui/tooltip' +import type { ResumeCandidate } from '../native-chat-resume-on-restart-grouping' +import { + consumeNativeChatResumeOnRestartDialogRequest, + getNativeChatResumeOnRestartDialogRequest +} from '../native-chat-resume-on-restart-dialog' +import { _resetNativeChatRestartOffer } from '../native-chat-resume-on-restart-store' +import { NativeChatResumeStatusSegment } from './NativeChatResumeStatusSegment' + +const rpc = vi.hoisted(() => vi.fn()) +vi.mock('@/runtime/structured-agent-session-client', () => ({ + callStructuredAgentSession: rpc +})) +vi.mock('sonner', () => ({ toast: vi.fn() })) + +const candidates: ResumeCandidate[] = [ + { + sessionId: 'a', + workspaceId: 'workspace', + agent: 'codex', + trigger: 'quit', + latestPrompt: 'Fix it', + recordedAt: 1 + }, + { + sessionId: 'b', + workspaceId: 'workspace', + agent: 'claude', + trigger: 'update', + latestPrompt: 'Review it', + recordedAt: 2 + } +] + +/** Mounts the segment and snoozes the launch dialog the offer raises, as the modal's close does. */ +async function mount(iconOnly = false): Promise { + await act(async () => { + render( + + + + ) + }) + act(() => consumeNativeChatResumeOnRestartDialogRequest()) +} + +describe('NativeChatResumeStatusSegment', () => { + beforeEach(() => { + rpc.mockReset() + _resetNativeChatRestartOffer() + consumeNativeChatResumeOnRestartDialogRequest() + useAppStore.setState({ + ...useAppStore.getInitialState(), + settings: { ...getDefaultSettings(''), experimentalStructuredNativeChat: true } + }) + }) + + afterEach(() => { + cleanup() + _resetNativeChatRestartOffer() + consumeNativeChatResumeOnRestartDialogRequest() + useAppStore.setState(useAppStore.getInitialState(), true) + }) + + it('shows the host count and reopens the dialog on a fresh read', async () => { + rpc.mockResolvedValue({ sessions: candidates }) + await mount() + + expect(screen.getByRole('button', { name: '2 chats available to resume' })).toBeTruthy() + expect(screen.getByText('2 chats to resume')).toBeTruthy() + + expect(getNativeChatResumeOnRestartDialogRequest()).toBe(false) + await act(async () => screen.getByRole('button').click()) + // The launch read, then a second one taken before the dialog is allowed to reopen. + expect(rpc.mock.calls.map((call) => call[1])).toEqual([ + 'agentSession.restartResumable', + 'agentSession.restartResumable' + ]) + expect(getNativeChatResumeOnRestartDialogRequest()).toBe(true) + }) + + it('names a single chat in the singular', async () => { + rpc.mockResolvedValue({ sessions: candidates.slice(0, 1) }) + await mount() + + expect(screen.getByRole('button', { name: '1 chat available to resume' })).toBeTruthy() + expect(screen.getByText('1 chat to resume')).toBeTruthy() + }) + + // The count can lag the host — another window may have dismissed the offer. The re-read decides. + it('does not reopen the dialog when the host no longer offers anything', async () => { + rpc.mockResolvedValueOnce({ sessions: candidates }).mockResolvedValue({ sessions: [] }) + await mount() + + expect(getNativeChatResumeOnRestartDialogRequest()).toBe(false) + await act(async () => screen.getByRole('button').click()) + expect(getNativeChatResumeOnRestartDialogRequest()).toBe(false) + expect(screen.queryByRole('button')).toBeNull() + }) + + it('retries a transient host startup read before hiding a durable offer', async () => { + rpc + .mockRejectedValueOnce(new Error('host-starting')) + .mockResolvedValue({ sessions: candidates }) + await mount() + await act(async () => new Promise((resolve) => setTimeout(resolve, 125))) + + expect(screen.getByRole('button', { name: '2 chats available to resume' })).toBeTruthy() + expect(rpc.mock.calls.map((call) => call[1])).toEqual([ + 'agentSession.restartResumable', + 'agentSession.restartResumable' + ]) + }) + + it('hides when the feature is disabled or the host offers nothing', async () => { + rpc.mockResolvedValue({ sessions: candidates }) + useAppStore.setState({ + settings: { ...getDefaultSettings(''), experimentalStructuredNativeChat: false } + }) + await mount() + expect(screen.queryByRole('button')).toBeNull() + // Nothing is even asked of the host while the feature is off. + expect(rpc).not.toHaveBeenCalled() + + cleanup() + rpc.mockResolvedValue({ sessions: [] }) + useAppStore.setState({ + settings: { ...getDefaultSettings(''), experimentalStructuredNativeChat: true } + }) + await mount() + expect(screen.queryByRole('button')).toBeNull() + }) + + it('renders a compact count in icon-only mode', async () => { + rpc.mockResolvedValue({ sessions: candidates }) + await mount(true) + + expect(screen.getByRole('button').textContent).toContain('2') + expect(screen.queryByText('2 chats to resume')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx new file mode 100644 index 00000000000..65969411f5c --- /dev/null +++ b/src/renderer/src/components/status-bar/NativeChatResumeStatusSegment.tsx @@ -0,0 +1,81 @@ +import { RotateCcw } from 'lucide-react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import { useAppStore } from '@/store' +import { requestNativeChatResumeOnRestartDialog } from '../native-chat-resume-on-restart-dialog' +import { + refreshNativeChatRestartOffer, + useNativeChatRestartOffer +} from '../native-chat-resume-on-restart-store' + +// 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`. + +/** 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. */ +async function reopenOffer(): Promise { + const offered = await refreshNativeChatRestartOffer() + if (offered.length > 0) { + requestNativeChatResumeOnRestartDialog() + } +} + +export function NativeChatResumeStatusSegment({ + iconOnly +}: { + iconOnly: boolean +}): React.JSX.Element | null { + const structuredEnabled = useAppStore( + (store) => store.settings?.experimentalStructuredNativeChat === true + ) + const { candidates } = useNativeChatRestartOffer(structuredEnabled) + if (!structuredEnabled || candidates.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 } + ) + return ( + + + + + + {translate( + 'auto.components.status.bar.NativeChatResumeStatusSegment.tooltip', + 'Open interrupted chats available to resume' + )} + + + ) +} diff --git a/src/renderer/src/components/status-bar/StatusBarSurface.tsx b/src/renderer/src/components/status-bar/StatusBarSurface.tsx index d2528435a98..dfdcbb926db 100644 --- a/src/renderer/src/components/status-bar/StatusBarSurface.tsx +++ b/src/renderer/src/components/status-bar/StatusBarSurface.tsx @@ -19,6 +19,7 @@ import { StatusBarUsageEmptyCta } from './StatusBarUsageEmptyCta' import { UsagePercentageDisplayChangeNotice } from './UsagePercentageDisplayChangeNotice' import { UpdateStatusSegment } from './UpdateStatusSegment' import { SkillUpdateStatusSegment } from './SkillUpdateStatusSegment' +import { NativeChatResumeStatusSegment } from './NativeChatResumeStatusSegment' import { CaffeinateStatusSegment } from './CaffeinateStatusSegment' import { RemoteServerUpdateStatusSegment } from './RemoteServerUpdateStatusSegment' import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal' @@ -246,6 +247,7 @@ export function StatusBarSurface({ {!isPairedWebClientWindow() ? : null} + {petEnabled ? : null} diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index bfebd0b81ae..f49cdcce869 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -76,16 +76,7 @@ "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.", - "manyAgents": "{{value0}} agents", - "oneAgent": "1 agent", - "projects": "Folder workspaces", - "reconnectAgent": "Reconnect {{value0}} chat", - "reconnectRefused_one": "{{value0}} chat could not be reconnected. You can still open it normally.", - "reconnectRefused_other": "{{value0}} chats could not be reconnected. You can still open them normally.", - "reconnectUnconfirmed_one": "Reconnection is unconfirmed for {{value0}} chat. You can still open it normally.", - "reconnectUnconfirmed_other": "Reconnection is unconfirmed for {{value0}} chats. You can still open them normally.", - "resume": "Reconnect" + "continueUnconfirmed_other": "Continuation delivery is unconfirmed for {{value0}} chats. Open them to check before sending another message." }, "NewWorkspaceComposerCard": { "0e587e31fb": "yaml", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7a8eb085539..b7e106c255c 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1948,44 +1948,27 @@ "9b40d7b018": "Copy" }, "NativeChatResumeOnRestartModal": { - "title": "Reconnect interrupted chats?", - "body": "These chats were mid-turn when Orca closed. Reconnecting restores each one where it stopped, with its full context and without re-sending your prompt — the interrupted reply will not continue on its own.", - "updateBody": "These chats were mid-turn when Orca installed an update. Reconnecting restores each one where it stopped, with its full context and without re-sending your prompt — the interrupted reply will not continue on its own.", - "terminalSessionsUnaffected": "Only chats are affected — your terminal sessions kept running and need nothing from you.", - "resume": "Reconnect", - "reconnectAgent": "Reconnect {{value0}} chat", - "resumeAll": "Reconnect all", - "resuming": "Reconnecting…", - "notNow": "Not now", + "title": "Resume interrupted chats?", + "body": "These chats were mid-turn when Orca closed. Resuming restores each one where it stopped, with its full context, and asks the agent to check its last action before carrying on. Your own prompt is not re-sent.", + "updateBody": "These chats were mid-turn when Orca installed an update. Resuming restores each one where it stopped, with its full context, and asks the agent to check its last action before carrying on. Your own prompt is not re-sent.", + "resuming": "Resuming…", "untitled": "Untitled chat", - "listLabel": "Chats that would be reconnected", - "notNowHint": "Not now keeps everything — you can reopen any chat later and carry on from the same point.", - "dontAskAgain": "Don't ask again — reconnect automatically next time", - "dontAskAgainHint": "Qualifying chats will be reconnected automatically after a restart, and Orca will tell you when it happens. You can turn this off in Settings → Experimental → Chat UI.", - "resumedOne": "Reconnected 1 chat", - "resumedMany": "Reconnected {{value0}} chats", - "oneAgent": "1 agent", - "manyAgents": "{{value0}} agents", - "reconnectAndContinue": "Reconnect and continue", - "continuedOne": "Reconnected 1 chat and asked it to continue", - "continuedMany": "Reconnected {{value0}} chats and asked them to continue", - "whatIsSentTitle": "What Orca sends", - "whatIsSentBody": "Continuing sends one short message to each agent, telling it that Orca restarted and asking it to check its last action before carrying on. Your own prompt is never re-sent.", - "selectAgent": "Reconnect {{value0}} chat \"{{value1}}\" in {{value2}}", - "projects": "Folder workspaces", - "resumeSelected": "Reconnect {{value0}}", + "listLabel": "Chats that would be resumed", + "dontAskAgain": "Don't ask again (resume automatically)", + "dontAskAgainHint": "You can turn this off in Settings → Experimental → Chat UI.", + "continuedOne": "Resumed 1 chat and asked it to continue", + "continuedMany": "Resumed {{value0}} chats and asked them to continue", + "selectAgent": "Resume {{value0}} chat \"{{value1}}\" in {{value2}}", + "resumeSelected": "Resume {{value0}} chats", + "resumeSelectedOne": "Resume 1 chat", "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.", - "reconnectUnconfirmed": "Reconnection is unconfirmed for {{value0}} chats. You can still open them normally.", - "reconnectUnconfirmed_one": "Reconnection is unconfirmed for {{value0}} chat. You can still open it normally.", - "reconnectUnconfirmed_other": "Reconnection is unconfirmed for {{value0}} chats. You can still open them normally.", "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.", - "reconnectRefused": "{{value0}} chats could not be reconnected. You can still open them normally.", - "reconnectRefused_one": "{{value0}} chat could not be reconnected. You can still open it normally.", - "reconnectRefused_other": "{{value0}} chats could not be reconnected. You can still open them normally." + "dismissAll": "Dismiss all", + "dismissUnconfirmed": "Dismissing the resume offer was not confirmed — it may still be in the status bar." }, "StarNagCard": { "92b0f9d921": "is authenticated and try again.", @@ -4075,6 +4058,13 @@ "contact_note": "The host may still be running; only the Orca connection is unavailable.", "last_connected": "Last connected {{value0}}", "reconnect_attempt": "Attempt {{value0}}" + }, + "NativeChatResumeStatusSegment": { + "labelOne": "1 chat to resume", + "label": "{{value0}} chats to resume", + "ariaLabelOne": "1 chat available to resume", + "ariaLabel": "{{value0}} chats available to resume", + "tooltip": "Open interrupted chats available to resume" } } }, @@ -7136,9 +7126,9 @@ "structuredCopy": "Opt in to the host-owned structured chat runtime for Codex and Claude. Off keeps the existing terminal-backed chat path.", "structuredScope": "Local sessions only for now. WSL and remote execution hosts (including SSH) continue to use terminal chat, and Windows falls back to it unless Orca can read process start times.", "structuredToggleLabel": "Toggle updated structured native chat", - "resumeTitle": "Reconnect working chats automatically after a restart", - "resumeCopy": "When Orca quits or installs an update, chats that were mid-turn are offered again on the next launch. On, they are reconnected without asking and Orca tells you afterwards — the same thing as ticking \"Don't ask again\" in that prompt. Off, you choose from the list each time. Reconnecting restores a chat where it stopped; it does not continue the interrupted reply.", - "resumeToggleLabel": "Toggle automatic reconnect after a restart" + "resumeTitle": "Resume working chats automatically after a restart", + "resumeCopy": "When Orca quits or installs an update, chats that were mid-turn are automatically resumed when Orca is reopened.", + "resumeToggleLabel": "Toggle automatic resume after a restart" }, "agentDashboard": { "title": "Agent Dashboard", diff --git a/src/shared/agent-session-restart-continuation.ts b/src/shared/agent-session-restart-continuation.ts index cd6df0397d4..973caa10de7 100644 --- a/src/shared/agent-session-restart-continuation.ts +++ b/src/shared/agent-session-restart-continuation.ts @@ -5,9 +5,11 @@ // would make the two lanes behave differently — and the wording is the part that tells an agent to // VERIFY its last action before repeating it. Both reasons point the same way. // -// Sending this is ALWAYS a deliberate user action. Reconnecting never sends it, and the automatic -// path never reaches this module: see `structured-agent-session-restart-resume-host`, where the -// resume surface contains no send at all. +// Sending this needs the user's OPT-IN, not their presence: the restart prompt's resume sends it, +// and so does a launch the user ticked "resume automatically" for. That is acceptable because the +// work being continued is the user's own, the wording above tells the agent to VERIFY its last +// action before repeating it, and the launch reports what it did. Reattaching without a send +// remains a separate operation that never comes here. export const AGENT_SESSION_RESTART_CONTINUATION_MESSAGE = "Orca restarted, so your previous reply was cut off partway through. Before continuing, check whether your most recent action completed — don't repeat it if it did. Then carry on."