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