import { noticeCode, type ResumeNoticeReason } from "./sandbox-notice.js"; import type { TranscriptRow, TurnNote } from "./transcript.js"; // What a resumed turn's prompt says happened; lives on the wire since the client must recognise it too. // The shared prefix lets a joining window reuse the existing bubble, not render the note as a new message. // What follows the note is the original request repeated; `answered` carries the user's actual answer instead. const REPEATED = "The interrupted request is repeated below, where part of it was already completed in this session, continue from that point instead of starting over."; export const RESUME_NOTES = { auth: `The Claude credential that interrupted this conversation has been renewed, and this turn resumed automatically. ${REPEATED}`, outage: `The model provider was briefly unavailable and interrupted this conversation; this turn resumed automatically. ${REPEATED}`, restart: `The sandbox restarted while this turn was running, which stopped it, and this turn resumed automatically once it came back. ${REPEATED}`, // Anything that killed a turn with no remedy of its own: a runtime that hung, a harness that crashed. stopped: `The previous attempt at this request stopped before it finished, and it has been sent again. ${REPEATED}`, // Three notes, one allowance failure: `limit` continues, `switched` opens fresh, `refused` says the opposite. limit: `The model provider's usage allowance ran out while this turn was running, which stopped it, and it has been sent again. ${REPEATED}`, switched: "The model provider's usage allowance ran out while this turn was running, which stopped it, and it has been sent again on a different account, which starts a fresh session. The conversation so far has been carried across above, including the part of the request that was already completed, and the sandbox has measured where the work actually stands (the files changed on this branch, what was verified, what the checklist still holds) in the note headed 'Where the work stands': trust that note over anything recalled, then continue from that point instead of starting over.", // The two cold-cache hand-offs a person can pick (schemas/providers/handoff.ts) besides carrying the session whole. trimmed: `The model provider's usage allowance ran out while this conversation was running, and this turn has been sent again in a copy of the same session, to save re-reading it: every message and tool call is still here, but the output of older tool calls has been cleared (each says so where it was). Run a command or read a file again if you need what it returned. ${REPEATED}`, summarized: "The model provider's usage allowance ran out while this conversation was running, and this turn has been sent again in a fresh session, to save re-reading the old one. A summary of the conversation so far is in the note headed 'Summary of the conversation so far', the most recent part of it is carried across above, and the sandbox has measured where the work actually stands in the note headed 'Where the work stands': trust that note over the summary where they differ, then continue from that point instead of starting over.", // `carried` switches account but keeps the session, so REPEATED stays true here, unlike `switched`. carried: `The model provider's usage allowance ran out while this turn was running, which stopped it, and it has been sent again on a different account of the same provider, in this same session: everything you knew is still here. ${REPEATED}`, refused: "The model provider refused the previous attempt at this request outright, because its usage allowance was spent: no part of the request below was read or acted on, and nothing has been done towards it. It has been sent again, and starts from the beginning. Where the sandbox has measured earlier work on this branch, it is in the note headed 'Where the work stands'.", // Turned away before the model saw a word (a memory hold, a dead credential, a model the plan lacks), with the // sandbox keeping the turn: nothing to continue from, so the request starts at its beginning. door: "The previous attempt at this request was turned away before any of it reached you: no part of the request below was read or acted on. It has now been sent, and starts from the beginning.", // The session outgrew the model's window, so the re-run opens fresh with the hand-off, as `switched` does. overflow: "This conversation's session grew larger than the model's context window can hold, which stopped this turn, and it has been sent again in a fresh session. The conversation so far has been carried across above, including the part of the request that was already completed, and the sandbox has measured where the work actually stands (the files changed on this branch, what was verified, what the checklist still holds) in the note headed 'Where the work stands': trust that note over anything recalled, then continue from that point instead of starting over. Keep this session small: read large files and long command output in parts rather than whole.", // The provider's safety classifier stopped the turn partway, usually a false positive on ordinary work. Re-run in // the same session with the stopped response cut away, so the model sees none of it, on whichever model the press // named. flagged: `The model provider's safety classifier stopped the previous attempt at this request partway, and it has been sent again after the person looking at this conversation chose to go on. The stopped response has been removed from this session. ${REPEATED}`, // A person's Continue on a turn the sandbox kept nothing of (a Stop, a restart since): no request follows, since the // session already holds it, and nothing of the person's is recorded, since they wrote nothing. continued: "The previous turn in this conversation ended before it finished (it was stopped, or something cut it short, such as a sandbox restart), and the person looking at this conversation chose to carry on without writing anything new: continue the work from where the conversation left off. Anything they declined along the way stays declined.", // A turn parked on the user when the daemon died; what follows the note is their actual answer, not a repeat. answered: "The sandbox restarted while this conversation was waiting for the user to respond; it is back, and their response follows below: continue from where the session left off.", } as const; // The prompt a resume actually sends: the note (each carries its own account of what follows), then the original words. export const withResumeNote = (prompt: string, note: string): string => Object.values(RESUME_NOTES).some((known) => prompt.startsWith(known)) ? prompt : `${note}\n\n${prompt}`; /** * A note with what this one re-run alone can say added in its own paragraph's tail: what the restart cost the turn (its * background commands, /tmp, why it happened). The note itself stays the prefix every reader recognises, so a prompt * stored before tails existed, and one with a tail, both read as the same re-run. A tail holds no blank line: the first * one ends the note. */ export const resumeNoteWith = (note: string, tail: string | undefined): string => { const said = tail?.replaceAll(/\n\s*\n/g, "\n").trim(); return said === undefined || said === "" ? note : `${note} ${said}`; }; // The user's own words inside a resumed prompt, note and explanation stripped. Returns the prompt unchanged when it // isn't a resume, so any attach head can pass through it. A note standing alone (`continued`) leaves no words, and a // note's tail (resumeNoteWith) goes with it, up to the blank line that ends it. export const withoutResumeNote = (prompt: string): string => { const note = Object.values(RESUME_NOTES).find((known) => prompt.startsWith(known)); if (note === undefined) { return prompt; } const rest = prompt.slice(note.length); if (rest.startsWith("\n\n")) { return rest.slice(2); } if (!rest.startsWith(" ")) { return rest; } const end = rest.indexOf("\n\n"); return end === -1 ? "" : rest.slice(end + 2); }; export type ResumeReason = keyof typeof RESUME_NOTES; // How a resumed turn's interruption reads to a person: `notice` for the whole-turn re-runs, a muted line with the // repeat dropped; `note` for the answered case, the user's real answer disclosed normally. // A notice names its reason too, so the row it becomes can say it in the reader's language (sandbox-notice.ts). export type ResumeDisclosure = | { readonly kind: "notice"; readonly text: string; readonly reason: ResumeNoticeReason } | { readonly kind: "note"; readonly note: TurnNote }; const notice = (reason: ResumeNoticeReason, text: string): ResumeDisclosure => ({ kind: "notice", text, reason }); const RESUME_DISCLOSURES: Record = { auth: notice("auth", "Claude sign-in renewed, this turn picked up where it left off."), outage: notice("outage", "The model provider came back, this turn picked up where it left off."), // Says what a restart costs a turn, since the turn re-runs as if nothing changed and finds its servers gone. restart: notice( "restart", "The sandbox came back; background commands and dev servers this turn started have ended; this turn picked up where it left off.", ), stopped: notice("stopped", "Sent again after the turn stopped short, picking up where it left off."), // None of these four auto-resumed, a person pressed Continue; `switched`/`carried` name the differing account. limit: notice("limit", "Sent again after the allowance ran out mid-turn, picking up where it left off."), switched: notice("switched", "Sent again on the switched account after the allowance ran out mid-turn, in a fresh session."), carried: notice("carried", "Sent again on the switched account after the allowance ran out mid-turn, carrying the session with it."), trimmed: notice("trimmed", "Sent again after the allowance ran out, in a copy of the session with older tool output cleared."), summarized: notice("summarized", "Sent again after the allowance ran out, in a fresh session opened with a summary of the conversation."), refused: notice("refused", "Sent again after the allowance refused it: nothing had run."), door: notice("door", "Sent again: the first attempt was turned away before anything ran."), overflow: notice("overflow", "Sent again in a fresh session after the last one outgrew the model's context window."), flagged: notice("flagged", "Sent again after the model's safeguards stopped it, without the stopped response."), continued: notice("continued", "Carried on from where the last turn stopped."), answered: { kind: "note", note: { title: "Picked back up after a sandbox restart", text: RESUME_NOTES.answered } }, }; /** The row a whole-turn re-run stands in for its repeated words with, wherever a reader restores one. */ export const resumeNoticeRow = (resume: Extract): TranscriptRow => ({ role: "notice", text: resume.text, noticeCode: noticeCode({ code: "resumed", params: { reason: resume.reason } }), }); // What a stored prompt's resume note should be shown as; undefined when the prompt isn't a resume, so any reader can // ask without checking first. export const resumeDisclosure = (prompt: string): ResumeDisclosure | undefined => { const reason = (Object.keys(RESUME_NOTES) as ResumeReason[]).find((key) => prompt.startsWith(RESUME_NOTES[key])); return reason === undefined ? undefined : RESUME_DISCLOSURES[reason]; };