Skip to content
4 changes: 4 additions & 0 deletions changelog.d/copilot-refresh-retention.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Fixed
- Copilot Chat keeps its messages while retrying a failed transcript page read when the same
conversation is still verified, instead of briefly showing the terminal fallback.
- A failed read of the gap between Chat pages no longer discards the messages already displayed.
54 changes: 53 additions & 1 deletion scripts/chat-history-browser-qa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ try {
let holdOlder = false;
let product = false;
let unavailable = false, terminalFails = false;
let readPending = false, gapPending = false;
let holdTerminal = false;
let terminalText = "Fictional terminal fallback";
/** product panes ("machine/pane") whose conversation requests wait for the script, and those that fail */
Expand Down Expand Up @@ -105,6 +106,19 @@ try {
await route.fulfill({ json: { source: "scrollback", turns: [] } });
return;
}
if (readPending || (gapPending && url.searchParams.has("before"))) {
const displayed = url.searchParams.get("history_id");
if (displayed !== null && displayed !== epoch) {
await route.fulfill({ status: 409, json: { error: { code: "history_changed", message: "reload history" } } });
return;
}
if (displayed === null && !url.searchParams.has("before") && !url.searchParams.has("from")) {
await route.fulfill({ json: { source: "scrollback", turns: [] } });
return;
}
await route.fulfill({ status: 503, json: { error: { code: "conversation_read_pending", message: "The conversation could not be read; try again shortly." } } });
return;
}
const captured = epoch;
const cursor = url.searchParams.get("before") ?? url.searchParams.get("from");
if (cursor && !cursor.startsWith(`${epoch}:`)) {
Expand All @@ -114,7 +128,7 @@ try {
if (url.searchParams.has("before")) {
routeEvents.dispatchEvent(new Event("older"));
if (holdOlder) await new Promise<void>((resolve) => releases.push(resolve));
await route.fulfill({ json: { source: "omp-transcript", history_id: captured, cursor: null, turns: [user("old earlier")] } });
await route.fulfill({ json: { source: "omp-transcript", history_id: captured, cursor: url.searchParams.get("since"), turns: [user("old earlier")] } });
} else {
const tool = { role: "assistant", ts: "reused", parts: [{ kind: "tool", name: "Read", summary: "same", input: "{}", output: "preview", output_ref: "reused", output_size: 9000 }] };
await route.fulfill({ json: { source: "omp-transcript", history_id: epoch, cursor: before, turns: newest ? [user(newest), tool] : [] } });
Expand All @@ -123,6 +137,44 @@ try {
await page.goto(`http://127.0.0.1:${server.port}/`);
await page.getByText("old newest", { exact: true }).waitFor();
const refresh = () => page.evaluate(() => window.qa.refresh());
await page.locator(".work-block-head").click();
await page.locator(".work-row-head").click();
const retainedTurn = await page.getByText("old newest", { exact: true }).elementHandle();
for (const gap of [false, true]) {
readPending = !gap; gapPending = gap;
if (gap) before = "one:200";
const failed = page.waitForResponse((response) => response.url().includes("/conversation?") && response.status() === 503);
await refresh();
await failed;
await page.getByText("Couldn't load the conversation", { exact: true }).waitFor();
assert.equal(await retainedTurn!.evaluate((node) => node.isConnected), true, "failed newest/gap reads keep the same mounted turn");
assert.equal(await page.locator(".work-block-head").getAttribute("aria-expanded"), "true", "failed reads keep opened work");
assert.equal(await page.locator(".work-row-head").getAttribute("aria-expanded"), "true", "failed reads keep opened tool details");
assert.equal(await page.locator(".chat-terminal-fallback").count(), 0, "a retryable read does not replace the conversation");
readPending = false; gapPending = false;
await refresh();
await page.getByText("Couldn't load the conversation", { exact: true }).waitFor({ state: "hidden" });
assert.equal(await page.getByText("old newest", { exact: true }).isVisible(), true);
}
// A session switched before an unpaged read fails: retrying it must not retain the old session.
await page.evaluate(() => window.qa.chat("switched-before-read"));
await page.waitForFunction(() => window.qa.answered.includes("local/switched-before-read"));
await page.getByText("old newest", { exact: true }).waitFor();
const switchedTurn = await page.getByText("old newest", { exact: true }).elementHandle();
epoch = "different-session"; before = null; readPending = true;
const rejectedHistory = page.waitForResponse((response) => response.url().includes("/conversation?") && response.status() === 409);
const freshFallback = page.waitForResponse((response) => response.url().includes("/conversation?") && !new URL(response.url()).searchParams.has("history_id") && response.status() === 200);
await refresh();
await rejectedHistory; await freshFallback;
await page.getByText("old newest", { exact: true }).waitFor({ state: "hidden" });
assert.equal(await switchedTurn!.evaluate((node) => node.isConnected), false, "an unpaged history mismatch clears old turns even when its fresh retry fails");
assert.equal(await page.getByText("old newest", { exact: true }).count(), 0);
readPending = false;
// Restore the original single-page fixture before the existing history-change cases.
epoch = "one-reset"; before = "one-reset:100";
await refresh();
await page.getByText("old newest", { exact: true }).waitFor();
console.log("PASS newest and gap read failures retain mounted messages, opened work and tool details, then recover");
const output = page.locator("#output");
await page.locator("#fetch-output").click();
assert.equal(await page.evaluate(() => window.qa.requests.length), 1, "duplicate load deduplicated");
Expand Down
10 changes: 9 additions & 1 deletion server/conversation-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ export class ConversationUnavailable extends Error {
}
}

/** A read failed, but the pane still proves the same current conversation: retain the view and retry. */
export class ConversationReadPending extends Error {
constructor() {
super("The conversation could not be read; try again shortly.");
this.name = "ConversationReadPending";
}
}

/** The pane's agent holds a session it has not written yet: a conversation with no turns, not a missing one. */
export class ConversationNotStarted extends ConversationUnavailable {
constructor(readonly sessionId: string, readonly source: RecognizedConversation["source"] = "omo-transcript", readonly identity: string = sessionId) {
Expand Down Expand Up @@ -70,4 +78,4 @@ export function answerVersion(key: string, signature: string): string {
* while it is still inside the newest page); with `before`, the page ending there,
* never reaching back past `since`.
*/
export type ConversationPage = { before?: string; since?: string; from?: string };
export type ConversationPage = { before?: string; since?: string; from?: string; history_id?: string };
50 changes: 43 additions & 7 deletions server/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ import { CODEX_IMAGE_REF, CODEX_TOOL_IMAGE_REF, codexOutputPicture, codexTranscr
import { AmbiguousClaudeStore, claudeAttachSession, claudeProcessSession, claudeTranscriptFile, defaultClaudeConfigDir, forgetClaudeSessionFile, forgetClaudeSessions, isClaudeProcess, processClaudeConfigDir } from "./claude-store.ts";
import { forgetGjcPane, forgetGjcState, gjcPidUnderShell, gjcTranscriptForPane, isGjcProcess, storeRelative } from "./gjc-runtime.ts";
import { isOmoProcess, omoProcessIdentity, omoSessionForPane, processEnviron } from "./omo.ts";
import { processStartedAtAsync } from "./process-start.ts";
import { processAlive, processStartedAtAsync } from "./process-start.ts";
import { ompBlobStore, ompPaneRoute, ompRouteOwns, ompRouteStore, ompRouteTranscript } from "./omp.ts";
import { forgetOpencodeRead, forgetOpencodeState, OPENCODE_IMAGE_REF, opencodeConversation, opencodeDatabasePath, opencodeImage, opencodeReadKey, opencodeSessionId, opencodeToolOutput } from "./opencode.ts";
import { piTranscriptPath, unwrittenSession } from "./pi.ts";
Expand All @@ -64,7 +64,7 @@ import { forgetAllPiIndexes, forgetPiIndex, piAbandonedTurns, piBranchRecords, p
import { copilotOffsetOutput, copilotOutput, copilotRootRecord, parseCopilotTranscript } from "./copilot.ts";
import { copilotExplicitResume, copilotHandoffSession, copilotProcessSession, copilotTranscriptFile, copilotUnwrittenSession, openCopilotFile } from "./copilot-store.ts";
import { parseConversationMetadata } from "./conversation-metadata.ts";
import { answerVersion, ConversationNotStarted, ConversationUnavailable, type ConversationPage, HistoryChanged, type RecognizedConversation, type StreamSource, TRANSCRIPT_WINDOW_BYTES } from "./conversation-shared.ts";
import { answerVersion, ConversationNotStarted, ConversationReadPending, ConversationUnavailable, type ConversationPage, HistoryChanged, type RecognizedConversation, type StreamSource, TRANSCRIPT_WINDOW_BYTES } from "./conversation-shared.ts";
import { forgetHermesTranscriptState, hermesConversationPage, hermesToolOutput, hermesTranscriptForPane } from "./hermes.ts";

import { createOmpTranscriptParser, isContextClear, MAX_TURNS, omoTaskResults, parseOmpTranscript, PI_IMAGE_REF, PI_PROMPT_IMAGE_REF, piImageBlock, piMessage, piNotice, piPromptImages, piResults, type TranscriptParser } from "./transcript-records.ts";
Expand All @@ -80,7 +80,7 @@ import { devinSessionForPane, isDevinProcess } from "./devin-session.ts";
export { isOmoProcess } from "./omo.ts";
export { devinSessionForPane, isDevinProcess } from "./devin-session.ts";
export { createClaudeTranscriptParser, parseClaudeTranscript, unwrapPastes } from "./claude-transcript.ts";
export { TRANSCRIPT_WINDOW_BYTES, ConversationUnavailable, ConversationNotStarted, HistoryChanged, answerVersion, type RecognizedConversation, type ConversationPage } from "./conversation-shared.ts";
export { TRANSCRIPT_WINDOW_BYTES, ConversationReadPending, ConversationUnavailable, ConversationNotStarted, HistoryChanged, answerVersion, type RecognizedConversation, type ConversationPage } from "./conversation-shared.ts";

const PI_PAGE_CACHE_BYTES = 4 * 1024 * 1024;

Expand Down Expand Up @@ -1121,17 +1121,23 @@ async function ompTranscriptPath(paneId: string, cwd: string): Promise<{ path: s
}

/** A process-owned lifecycle registration, or a trusted report plus a unique held marker. */
async function copilotTranscriptPath(pane: HerdrPane, panes: readonly HerdrPane[], previousClaims?: ReadonlySet<string>): Promise<{ path: string; binding: string; peerClaims: ReadonlySet<string> }> {
async function copilotTranscriptPath(pane: HerdrPane, panes: readonly HerdrPane[], previousClaims?: ReadonlySet<string>, peerInfo?: Map<string, Awaited<ReturnType<typeof paneProcessInfo>> | null>): Promise<{ path: string; binding: string; peerClaims: ReadonlySet<string> }> {
const report = pane.agent_session;
// A request shares its peer snapshot across the read; the pane's own process
// and lifecycle evidence are still validated afresh before returning history.
// With `peerInfo`, peers' processes come from (and fill) that cache, while their
// lifecycle logs are read again here, after this pane's own binding.
let peerClaims: Promise<ReadonlySet<string>> | undefined = previousClaims === undefined ? undefined : Promise.resolve(previousClaims);
const gatherPeerClaims = async (): Promise<Set<string>> => {
const claims = new Set<string>();
for (const peer of panes) {
if (peer.pane_id === pane.pane_id || peer.agent !== "copilot") continue;
const info = await paneProcessInfo(peer.pane_id).catch(() => null);
const cached = peerInfo?.has(peer.pane_id) === true;
const info = cached ? peerInfo!.get(peer.pane_id) : await paneProcessInfo(peer.pane_id).catch(() => null);
peerInfo?.set(peer.pane_id, info ?? null);
const processes = agentProcesses(info?.foreground_processes ?? [], "copilot");
// A cached peer process that has exited may have been replaced by one we never asked about.
if (cached && processes.some((entry) => typeof entry.pid === "number" && !processAlive(entry.pid))) throw new ConversationUnavailable("no_session_path");
if (processes.length === 1 && typeof processes[0]?.pid === "number") {
const live = copilotProcessSession(processEnviron(processes[0].pid), processes[0].argv ?? [], processes[0].pid);
if (live?.logged) {
Expand Down Expand Up @@ -1468,7 +1474,37 @@ export async function paneConversation(paneId: string, codexHome?: string, page:
rememberPaneRead(paneId, resolved.path);
const history = resolved.source === "copilot-transcript" ? copilotHistoryIdentity(resolved.path) : null;
const prefix = history !== null && resolved.source === "copilot-transcript" ? copilotPrefix(paneId, `${resolved.binding}\0${history}`) : null;
const answer = transcriptPage(resolved.source, resolved.path, prefix === null ? page : copilotPage(page, prefix), resolved.codexHome ?? codexHome, resolved.piAgentDir);
let answer: RecognizedConversation;
try {
answer = transcriptPage(resolved.source, resolved.path, prefix === null ? page : copilotPage(page, prefix), resolved.codexHome ?? codexHome, resolved.piAgentDir);
} catch (error) {
if (resolved.source !== "copilot-transcript" || prefix === null || !(error instanceof ConversationUnavailable) || error.message !== "transcript_missing") throw error;
const paged = page.before !== undefined || page.since !== undefined || page.from !== undefined;
// Unpaged refreshes otherwise carry no proof of which history is on screen: older
// clients without it keep their fail-closed fallback, with no recheck to wait on.
if (page.history_id === undefined && !paged) throw error;
// A read failure is not an empty conversation. Only a fresh, matching binding
// and file generation permit retaining the view; never reuse the request's earlier peer claims.
try {
const fresh = await sessionSnapshot();
const pane = fresh.panes.find((candidate) => candidate.pane_id === paneId);
if (pane === undefined) throw new ConversationUnavailable("no_session_id");
// Peer checks await herdr, and either side can switch meanwhile: once every
// process is known, read this pane's binding and the peers' claims again with no
// herdr call between those reads and the comparison.
const peerInfo = new Map<string, Awaited<ReturnType<typeof paneProcessInfo>> | null>();
await copilotTranscriptPath(pane, fresh.panes, undefined, peerInfo);
const current = await copilotTranscriptPath(pane, fresh.panes, undefined, peerInfo);
if (current.path !== resolved.path || current.binding !== resolved.binding || copilotHistoryIdentity(resolved.path) !== history) {
throw new ConversationUnavailable("no_session_path");
}
} catch (cause) {
if (cause instanceof ConversationUnavailable && paged) throw new HistoryChanged();
throw cause;
}
if (page.history_id !== undefined && !page.history_id.startsWith(prefix)) throw new HistoryChanged();
throw new ConversationReadPending();
}
const status = snapshot.panes.find((pane) => pane.pane_id === paneId)?.agent_status;
if (resolved.source === "grok-transcript" && answer.turns.length === 0 && page.before === undefined
&& (status === "working" || status === "blocked")) {
Expand Down Expand Up @@ -1732,7 +1768,7 @@ function transcriptPageRead(source: StreamSource, path: string, page: Conversati
if (live === null && page.before === undefined && start > stream.floor) head = metadataHead(path, stream, source, start);
cursor = formatCursor(stream, start);
} catch (error) {
if (error instanceof HistoryChanged) throw error;
if (error instanceof HistoryChanged || error instanceof ConversationUnavailable) throw error;
throw new ConversationUnavailable("transcript_missing");
}

Expand Down
Loading
Loading