Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog.d/copilot-recent-tool-output.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
### Fixed
- Copilot chat can open older and recent tool results even after a transcript grows beyond 32 MiB.
44 changes: 31 additions & 13 deletions server/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ import { AgyUnavailable, agyCatalog, agyToolOutput, forgetAgyState, parseAgyTran
import { resolveAgyStore, sameAgyStore, type AgyStore } from "./agy-store.ts";
import { withinRetentionBudget } from "./retention-budget.ts";
import { forgetAllPiIndexes, forgetPiIndex, piAbandonedTurns, piBranchRecords, piBranchSegments } from "./pi-tree.ts";
import { copilotOutput, copilotRootRecord, parseCopilotTranscript } from "./copilot.ts";
import { copilotOffsetOutput, copilotOutput, copilotRootRecord, parseCopilotTranscript } from "./copilot.ts";
import { copilotExplicitResume, 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";
Expand Down Expand Up @@ -111,6 +111,16 @@ function copilotPrefix(paneId: string, identity: string): string {
return prefix;
}

function copilotHistoryIdentity(path: string): string {
try {
const fd = openCopilotFile(path);
try {
const stat = fstatSync(fd);
return `${stat.dev}:${stat.ino}${transcriptGeneration(path, stat)}`;
} finally { closeSync(fd); }
} catch { throw new ConversationUnavailable("transcript_missing"); }
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

function copilotPage(page: ConversationPage, prefix: string): ConversationPage {
const unbind = (cursor?: string): string | undefined => {
if (cursor === undefined) return undefined;
Expand Down Expand Up @@ -486,8 +496,8 @@ function subagentsOnPage(path: string, text: string): ReadonlyMap<string, Subage
return ids.size === 0 ? undefined : subagentDetails(path, ids);
}

function parseTurns(source: RecognizedConversation["source"], path: string, text: string, taskTitles?: Map<string, string>, noticed?: Set<string>): ConversationTurn[] {
if (source === "copilot-transcript") return parseCopilotTranscript(text, Infinity);
function parseTurns(source: RecognizedConversation["source"], path: string, text: string, taskTitles?: Map<string, string>, noticed?: Set<string>, baseOffset?: number): ConversationTurn[] {
if (source === "copilot-transcript") return parseCopilotTranscript(text, Infinity, baseOffset);
if (source === "grok-transcript") {
try { return parseGrokTranscript(text, path); } catch (error) {
if (error instanceof GrokUnavailable) throw new ConversationUnavailable("transcript_missing");
Expand Down Expand Up @@ -635,14 +645,14 @@ function liveTurns(path: string, stream: TranscriptStream, source: RecognizedCon
if (kept !== undefined) {
const from = kept.end <= start ? kept.end : kept.start;
const to = Math.min(start, kept.observedEnd);
if (to > from) parseTurns(source, path, readStream(stream, from, to).toString("utf8"), inherited);
if (to > from) parseTurns(source, path, readStream(stream, from, to).toString("utf8"), inherited, undefined, from);
}
settled = { source, id: stream.id, start, end: start, turns: [], metadata: parseConversationMetadata(`${head}
`, source, undefined, piAgentDir), tail: bytesBefore(stream, start), taskTitles: new Map(inherited), noticed: new Set(), inherited, observedEnd: stream.length, piAgentDir };
}
if (settled.end < last) {
const text = readStream(stream, settled.end, last).toString("utf8");
settled = { ...settled, end: last, turns: [...settled.turns, ...parseTurns(source, path, text, settled.taskTitles, settled.noticed)], metadata: parseConversationMetadata(text, source, settled.metadata, piAgentDir), tail: bytesBefore(stream, last) };
settled = { ...settled, end: last, turns: [...settled.turns, ...parseTurns(source, path, text, settled.taskTitles, settled.noticed, settled.end)], metadata: parseConversationMetadata(text, source, settled.metadata, piAgentDir), tail: bytesBefore(stream, last) };
}
settled.observedEnd = stream.length;
rememberPage(settledTurns, key, settled, 8);
Expand All @@ -653,7 +663,7 @@ function liveTurns(path: string, stream: TranscriptStream, source: RecognizedCon
const text = readStream(stream, last, stream.length).toString("utf8");
// the live turn is parsed again on every poll: its titles and completions are kept only
// once it settles, so a read titles a task exactly as a cold read of the same bytes does
return { turns: [...settled.turns, ...parseTurns(source, path, text, new Map(settled.taskTitles), new Set(settled.noticed))], metadata: parseConversationMetadata(text, source, settled.metadata, piAgentDir) };
return { turns: [...settled.turns, ...parseTurns(source, path, text, new Map(settled.taskTitles), new Set(settled.noticed), last)], metadata: parseConversationMetadata(text, source, settled.metadata, piAgentDir) };
}

/** Retention diagnostics only: counts/budget adherence, never transcript contents. */
Expand Down Expand Up @@ -1315,7 +1325,8 @@ export async function paneConversation(paneId: string, codexHome?: string, page:
: resolved.source === "claude-transcript" ? nodePath.basename(resolved.path, ".jsonl")
: resolved.source === "pi-transcript" || resolved.source === "cmd-transcript" || resolved.source === "omp-transcript" ? realpathSync(resolved.path) : null;
rememberPaneRead(paneId, resolved.path);
const prefix = resolved.source === "copilot-transcript" ? copilotPrefix(paneId, resolved.binding) : null;
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);
const status = snapshot.panes.find((pane) => pane.pane_id === paneId)?.agent_status;
if (resolved.source === "grok-transcript" && answer.turns.length === 0 && page.before === undefined
Expand Down Expand Up @@ -1345,7 +1356,8 @@ export async function paneConversation(paneId: string, codexHome?: string, page:
if (prefix !== null && resolved.source === "copilot-transcript") {
const pane = snapshot.panes.find((candidate) => candidate.pane_id === paneId)!;
const current = await copilotTranscriptPath(pane, snapshot.panes, resolved.peerClaims);
if (current.path !== resolved.path || current.binding !== resolved.binding) throw new HistoryChanged();
if (current.path !== resolved.path || current.binding !== resolved.binding
|| copilotHistoryIdentity(resolved.path) !== history) throw new HistoryChanged();
return bindCopilotAnswer(answer, prefix);
}
return answer;
Expand Down Expand Up @@ -1586,7 +1598,7 @@ function transcriptPageRead(source: StreamSource, path: string, page: Conversati
// the store decides the parser, not the pane's label: omo writes omp's
// session shape while herdr may be calling that same pane `claude`. The page
// bounds the turns, so none are cut: they must meet the next page exactly.
const turns = live?.turns ?? parseTurns(source, path, text);
const turns = live?.turns ?? parseTurns(source, path, text, undefined, undefined, start);
const metadata = live?.metadata ?? parseConversationMetadata(`${head}\n${text}`, source, undefined, piAgentDir);
// Record the stat from BEFORE the read: an append during parsing must cause
// another read on the next poll, not permanently cache a torn tail.
Expand Down Expand Up @@ -1894,7 +1906,7 @@ const TOOL_OUTPUT_MAX = 2_000_000;

/** The whole output of a tool call whose page output was cut, by its id; null when there is none. */
export async function toolOutput(paneId: string, ref: string, codexHome?: string, opencodeDb?: string, hermesHome?: string, grokHome?: string): Promise<string | null> {
if (!TOOL_REF.test(ref) && !/^cp[0-9a-f]{16}\.[A-Za-z0-9_:.-]{1,128}$/.test(ref)) return null;
if (!TOOL_REF.test(ref) && !/^cp[0-9a-f]{16}\.[A-Za-z0-9_:.-]{1,128}(?:@[0-9a-z]+@[0-9a-z]+@[0-9a-f]{32})?$/.test(ref)) return null;
const snapshot = await sessionSnapshot();
const pane = snapshot.panes.find((candidate) => candidate.pane_id === paneId);
if (pane === undefined || typeof pane.cwd !== "string" || pane.cwd.length === 0) return null;
Expand All @@ -1909,22 +1921,28 @@ export async function toolOutput(paneId: string, ref: string, codexHome?: string
if (resolved.source === "hermes-transcript") return hermesToolOutput(resolved.sessionId, resolved.path, ref, TOOL_OUTPUT_MAX);
let nativeRef = ref;
if (resolved.source === "copilot-transcript") {
const prefix = copilotPrefix(paneId, resolved.binding);
let history: string;
try { history = copilotHistoryIdentity(resolved.path); } catch { return null; }
const prefix = copilotPrefix(paneId, `${resolved.binding}\0${history}`);
if (!ref.startsWith(prefix)) return null;
nativeRef = ref.slice(prefix.length);
if (!nativeRef.includes("@")) return null;
}
const output = transcriptToolOutput(resolved.source, resolved.path, nativeRef, resolved.codexHome ?? codexHome);
if (resolved.agy && !sameAgyStore(resolved.agy, await resolveAgyStore(pane))) return null;
if (resolved.source === "copilot-transcript") {
const current = await copilotTranscriptPath(pane, snapshot.panes, resolved.peerClaims).catch(() => null);
if (current?.path !== resolved.path || current.binding !== resolved.binding) return null;
try {
if (!ref.startsWith(copilotPrefix(paneId, `${resolved.binding}\0${copilotHistoryIdentity(resolved.path)}`))) return null;
} catch { return null; }
}
return output;
}

/** The output a transcript file holds for one tool call id, whole (up to TOOL_OUTPUT_MAX). */
export function transcriptToolOutput(source: StreamSource, path: string, ref: string, codexHome?: string): string | null {
if (!TOOL_REF.test(ref)) return null;
if (!TOOL_REF.test(ref) && !(source === "copilot-transcript" && /^[A-Za-z0-9_:.-]{1,128}@[0-9a-z]+@[0-9a-z]+@[0-9a-f]{32}$/.test(ref))) return null;
if (source === "codex-transcript") {
let segments: ReturnType<typeof codexHistorySegments>;
try { segments = codexHistorySegments(path, codexHome); } catch { return null; }
Expand All @@ -1944,7 +1962,7 @@ export function transcriptToolOutput(source: StreamSource, path: string, ref: st
let fd: number;
try { fd = openCopilotFile(path); } catch { return null; }
try {
return copilotOutput(fd, ref, TOOL_OUTPUT_MAX);
return ref.includes("@") ? copilotOffsetOutput(fd, ref, TOOL_OUTPUT_MAX) : copilotOutput(fd, ref, TOOL_OUTPUT_MAX);
} finally { closeSync(fd); }
}
let text: string;
Expand Down
3 changes: 2 additions & 1 deletion server/copilot.contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,9 @@ it.skipIf(process.platform !== "linux")("binds only the reported UUID to the for
if (completed?.kind !== "tool" || !completed.output_ref) throw new Error("fixture tool output ref missing");
const completedRef = completed.output_ref;
expect(completed).toMatchObject({ kind: "tool", error: true });
expect(completedRef).toMatch(/^cp[0-9a-f]{16}\.call-1$/);
expect(completedRef).toMatch(/^cp[0-9a-f]{16}\.call-1@[0-9a-z]+@[0-9a-z]+@[0-9a-f]{32}$/);
expect((await fetch(toolUrl("call-1"))).status).toBe(404);
expect((await fetch(toolUrl(completedRef.slice(0, completedRef.indexOf("@"))))).status).toBe(404);
expect(await (await fetch(toolUrl(completedRef))).text()).toBe("error ".repeat(900));
appendFileSync(file(id), event("session.resume", { selectedModel: "fixture-model" }) + event("user.message", { content: "appended" }));
expect((await read()).history_id).toBe(first.history_id);
Expand Down
Loading
Loading