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
3 changes: 3 additions & 0 deletions changelog.d/copilot-page-split.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Fixed
- A Copilot chat no longer splits a turn whose pictures fill the page window: the newest page
showed only the answer, and the older one the tool calls without their output.
111 changes: 99 additions & 12 deletions server/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import { launchedAgents } from "./launched-agents.ts";
import { readsStartScreen, startScreenMetadata } from "./start-screen.ts";
import { commandTranscriptRecords, PI_EVIDENCE_TYPES, type CommandTranscriptRecord } from "./outcome.ts";
import { type ClaudeBranch, claudeForks, followClaudeBranch, type PromptChain, turnStarts } from "./transcript-turns.ts";
import { copilotSetAside } from "./copilot.ts";
import { devinSessionForPane, isDevinProcess } from "./devin-session.ts";
import { userHome } from "./user-home.ts";
import { kiroCodexProcess } from "./kiro-terminal.ts";
Expand All @@ -102,6 +103,12 @@ export const DEFAULT_PAGE_PROMPTS = MAX_PAGE_PROMPTS;

/** A single turn longer than a window still gets a page of its own, up to this. */
const MAX_PAGE_BYTES = 16 * 1024 * 1024;
/**
* How far back a Copilot page reaches for the start of its turn: Copilot interns every picture of a
* turn in it as base64 (`session.binary_asset`), so one turn with a few pictures passes a window
* (#2478). Its pages widen to the turn's start, the newest one too, up to this.
*/
const COPILOT_PAGE_BYTES = 4 * MAX_PAGE_BYTES;

/** The prompts a page keeps for this read: the reader's choice, inside the server's own bounds. */
function pagePrompts(page: ConversationPage): number {
Expand Down Expand Up @@ -436,22 +443,36 @@ function* applyHistoryBoundarySteps(path: string, stream: TranscriptStream, sour
* line that opens a turn, at `floor` (a held start) or at the very beginning. An
* older page is read once, so for a turn longer than a window it reaches further
* back, a new chunk at a time, up to MAX_PAGE_BYTES. The newest page is read on
* every append, so it never does: with no turn start in its window it starts
* mid-turn, at a whole line.
* every append, so it never does, but for Copilot's (COPILOT_PAGE_BYTES): with no turn
* start in its window it starts mid-turn, at a whole line.
*/
function pageBefore(stream: TranscriptStream, source: StreamSource, to: number, { floor = stream.floor, widen, prompts }: { floor?: number; widen: boolean; prompts: number }): { start: number; bytes: Buffer } {
let from = Math.max(floor, to - TRANSCRIPT_WINDOW_BYTES);
let bytes = readStream(stream, from, to);
const bytes = readStream(stream, from, to);
// the bytes searched for turn starts: the window, then each chunk widening adds before it
let searched = bytes;
for (;;) {
const starts = stream.promptStarts ? stream.promptStarts.filter((at) => at >= from && at < to).map((at) => at - from) : turnStarts(bytes, source, undefined, turnBlobs(stream, source));
const starts = stream.promptStarts ? stream.promptStarts.filter((at) => at >= from && at < from + searched.length).map((at) => at - from) : turnStarts(searched, source, undefined, turnBlobs(stream, source));
const keep = starts.length > prompts ? starts[starts.length - prompts] : from === floor ? 0 : starts[0];
if (keep !== undefined) return { start: from + keep, bytes: bytes.subarray(keep) };
if (!widen || to - from >= MAX_PAGE_BYTES) {
// a start found while widening is read from once, not built up a chunk at a time
if (keep !== undefined) return searched === bytes ? { start: from + keep, bytes: bytes.subarray(keep) } : { start: from + keep, bytes: readStream(stream, from + keep, to) };
if (!widen || to - from >= (source === "copilot-transcript" ? COPILOT_PAGE_BYTES : MAX_PAGE_BYTES)) {
const firstLine = bytes.indexOf(0x0a) + 1;
return { start: from + firstLine, bytes: bytes.subarray(firstLine) };
return { start: to - bytes.length + firstLine, bytes: bytes.subarray(firstLine) };
}
// no start in what was searched: only the new chunk can hold one, and the line it cuts ends past it
const next = Math.max(floor, from - TRANSCRIPT_WINDOW_BYTES);
bytes = Buffer.concat([readStream(stream, next, from), bytes]);
const pieces = [readStream(stream, next, from)];
for (let at = from; at < to;) {
const piece = readStream(stream, at, Math.min(to, at + IMAGE_SEARCH_WINDOW));
// a file cut short since its size was taken reads nothing more: what was read is searched
if (piece.length === 0) break;
const lineEnd = piece.indexOf(0x0a);
pieces.push(lineEnd === -1 ? piece : piece.subarray(0, lineEnd + 1));
if (lineEnd !== -1) break;
at += piece.length;
}
searched = Buffer.concat(pieces);
from = next;
}
}
Expand Down Expand Up @@ -586,6 +607,61 @@ function newestPage(path: string, stream: TranscriptStream, source: StreamSource
return start === undefined ? null : { start, starts };
}

/**
* The newest Copilot turn that has outgrown its window (#2478), kept between polls: where it starts,
* how far it was read and the bytes just before that, and its complete lines, a picture record as its
* length only (copilotSetAside). A poll reads only what was appended since; another history, a file
* whose bytes before the read point changed, a new prompt, or a turn grown a window past the cap is
* read afresh. Like liveScans it trusts Copilot to append: an earlier line rewritten in place, with
* the bytes before the read point kept and the file grown, is not noticed.
*/
interface CopilotTurnScan { id: string; start: number; scanned: number; tail: string; lines: { at: number; line: string }[]; bytes: number }
const copilotTurnScans = new Map<string, CopilotTurnScan>();
/** What every kept turn's lines may come to together: one turn as far as a page reaches before it is found afresh. */
const COPILOT_TURN_SCAN_BYTES = COPILOT_PAGE_BYTES + TRANSCRIPT_WINDOW_BYTES;

function forgetCopilotTurns(path: string): void {
for (const key of copilotTurnScans.keys()) if (key.startsWith(`${path}\0`)) copilotTurnScans.delete(key);
}

/** The newest Copilot page that begins before its window: its start, its complete lines and the last line still being written. */
function copilotNewestTurn(path: string, stream: TranscriptStream, prompts: number): { start: number; lines: CopilotTurnScan["lines"]; partial: string } {
const key = `${path}\0${prompts}`;
let scan = copilotTurnScans.get(key);
let bytes: Buffer | null = null;
// a turn grown a window past the cap is found afresh, so a page still ends up at most about the cap
if (scan && scan.id === stream.id && scan.scanned <= stream.length && stream.length - scan.start <= COPILOT_PAGE_BYTES + TRANSCRIPT_WINDOW_BYTES
&& bytesBefore(stream, scan.scanned) === scan.tail) {
bytes = readStream(stream, scan.scanned, stream.length);
// a prompt appended since starts a newer turn: the page is found afresh from it
if (turnStarts(bytes.subarray(0, bytes.lastIndexOf(0x0a) + 1), "copilot-transcript", undefined, null).length > 0) bytes = null;
}
if (scan === undefined || bytes === null) {
const whole = pageBefore(stream, "copilot-transcript", stream.length, { widen: true, prompts });
scan = { id: stream.id, start: whole.start, scanned: whole.start, tail: "", lines: [], bytes: 0 };
bytes = whole.bytes;
}
let at = 0;
for (let newline = bytes.indexOf(0x0a); newline !== -1; newline = bytes.indexOf(0x0a, at)) {
const line = copilotSetAside(bytes.subarray(at, newline).toString("utf8"));
scan.lines.push({ at: scan.scanned + at, line });
scan.bytes += Buffer.byteLength(line);
at = newline + 1;
}
scan.scanned += at;
scan.tail = bytesBefore(stream, scan.scanned);
remember(copilotTurnScans, key, scan, 4);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
// kept by bytes too, least recently read first out, and never a turn past the whole budget
let kept = 0;
for (const entry of copilotTurnScans.values()) kept += entry.bytes;
for (const [other, entry] of copilotTurnScans) {
if (kept <= COPILOT_TURN_SCAN_BYTES) break;
copilotTurnScans.delete(other);
kept -= entry.bytes;
}
return { start: scan.start, lines: scan.lines, partial: bytes.subarray(at).toString("utf8") };
}

/** Only native notification records name metadata files; quoted tool output names none. */
function collectSubagentIds(text: string, ids: Set<string>): void {
if (!text.includes("<task-notification>")) return;
Expand Down Expand Up @@ -846,6 +922,7 @@ export function forgetTranscriptState(): void {
forgetOpencodeState();
forgetOmpBlobTypes();
liveScans.clear();
copilotTurnScans.clear();
settledTurns.clear();
liveTranscriptTurns.clear();
transcriptRevisions.clear();
Expand Down Expand Up @@ -887,6 +964,7 @@ export function forgetPaneTranscriptState(paneId: string): void {
transcriptRevisions.delete(path);
commandTranscripts.delete(path);
liveScans.delete(path);
forgetCopilotTurns(path);
for (const key of [...settledTurns.keys()]) if (key.startsWith(`${path}\0`)) settledTurns.delete(key);
liveTranscriptTurns.delete(path);
clearScans.delete(path);
Expand Down Expand Up @@ -1973,14 +2051,23 @@ function transcriptPageRead(source: StreamSource, path: string, page: Conversati
// page has moved past it, the chat gets the newest page and fetches the turns
// in between with `before` + `since`: no poll reads more than a page.
if (newest !== null) {
// a turn the window holds the start of again: a later one outgrowing it is found afresh
if (source === "copilot-transcript") forgetCopilotTurns(path);
start = held !== null && held >= newest.start ? held : newest.start;
live = liveTurns(path, stream, source, start, newest.starts, piAgentDir);
text = "";
} else {
// no turn starts in the window: the page begins mid-turn, read whole as before
const whole = pageBefore(stream, source, stream.length, { widen: false, prompts: pagePrompts(page) });
start = held !== null && held >= whole.start ? held : whole.start;
text = whole.bytes.subarray(start - whole.start).toString("utf8");
// no turn starts in the window: the page begins mid-turn, read whole as before; a Copilot
// page reaches back to its turn's start (COPILOT_PAGE_BYTES) and reads only what was appended since
if (source === "copilot-transcript") {
const turn = copilotNewestTurn(path, stream, pagePrompts(page));
start = held !== null && held >= turn.start ? held : turn.start;
text = turn.lines.filter(({ at }) => at >= start).map(({ line }) => `${line}\n`).join("") + turn.partial;
} else {
const whole = pageBefore(stream, source, stream.length, { widen: false, prompts: pagePrompts(page) });
start = held !== null && held >= whole.start ? held : whole.start;
text = whole.bytes.subarray(start - whole.start).toString("utf8");
}
}
}
if (live === null && page.before === undefined && start > stream.floor) head = metadataHead(path, stream, source, start);
Expand Down
55 changes: 55 additions & 0 deletions server/copilot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1554,3 +1554,58 @@ it.skipIf(process.platform !== "linux")("follows process-owned foreground eviden
expect(copilotTranscriptFile(session, options, args, pid)).toBeNull();
} finally { await fixture.close(); }
});

it("keeps a recorded turn whole when the pictures Copilot interned in it pass the page window (#2478)", () => {
// the real recorded turn, with two interned pictures (Copilot 1.0.105's `session.binary_asset`) between its
// tool call and the call's result, together more than one page window of bytes
const recorded = readFileSync(new URL("./fixtures/copilot-recorded.jsonl", import.meta.url), "utf8").split("\n").filter(Boolean);
const complete = recorded.findIndex((record) => record.includes('"tool.execution_complete"'));
const picture = (n: number) => JSON.stringify({ type: "session.binary_asset", data: { assetId: `sha256:${String(n).repeat(64)}`, type: "image", mimeType: "image/png", byteLength: 6_700_000, data: "A".repeat(9 * 1024 * 1024) }, id: crypto.randomUUID(), timestamp: "2026-01-01T00:00:00.000Z", parentId: null });
const dir = mkdtempSync(join(tmpdir(), "copilot-page-split-"));
const path = join(dir, "events.jsonl");
try {
// an earlier turn first, so the pictured turn starts mid-file and an older page has something to hold
const earlier = recorded.filter((record) => /"type":"(user\.message|assistant\.message)"/.test(record) && !record.includes("toolRequests\":[{"))
.map((record) => record.replace("Recorded fixture text.", "Earlier turn."));
writeFileSync(path, [recorded[0]!, ...earlier, ...recorded.slice(1, complete), picture(1), picture(2), ...recorded.slice(complete)].join("\n") + "\n");
// more than a page window (TRANSCRIPT_WINDOW_BYTES, 16 MiB)
expect(statSync(path).size).toBeGreaterThan(16 * 1024 * 1024);
const whole = parseCopilotTranscript(readFileSync(path, "utf8"));
expect(whole.map((turn) => turn.role)).toEqual(["user", "assistant", "user", "assistant"]);
// one page at a prompt each: the newest holds the pictured turn whole, the older one the turn before it
const latest = transcriptPage("copilot-transcript", path, { prompts: 1 });
expect(latest.turns).toEqual(whole.slice(2));
expect(latest.turns[1]?.parts[0]).toMatchObject({ kind: "tool", name: "shell", output: "Recorded fixture text." });
expect(latest.cursor).not.toBeNull();
const older = transcriptPage("copilot-transcript", path, { before: latest.cursor!, prompts: 1 });
expect(older.turns).toEqual(whole.slice(0, 2));
// while the turn is live, a poll reads what was appended, not the turn again
const appended = JSON.stringify({ type: "assistant.message", data: { content: "Appended answer.", toolRequests: [], phase: "final_answer" }, id: crypto.randomUUID(), timestamp: "2026-01-01T00:00:01.000Z", parentId: null }) + "\n";
appendFileSync(path, appended);
let read = 0;
const realRead = fs.readSync;
const counting = spyOn(fs, "readSync").mockImplementation(((...args: Parameters<typeof fs.readSync>) => {
const count = (realRead as (...args: Parameters<typeof fs.readSync>) => number)(...args);
read += count;
return count;
}) as typeof fs.readSync);
let grown: ReturnType<typeof transcriptPage>;
try { grown = transcriptPage("copilot-transcript", path, { prompts: 1 }); } finally { counting.mockRestore(); }
// the spy sees the page's own reads: the appended record is read, and nothing much besides
expect(read).toBeGreaterThan(0);
expect(read).toBeLessThan(64 * 1024);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
expect(grown.turns).toEqual(parseCopilotTranscript(readFileSync(path, "utf8")).slice(2));
expect(grown.turns.at(-1)?.parts.at(-1)).toEqual({ kind: "text", text: "Appended answer.", phase: "final_answer" });
// a file written anew rather than appended to is read whole again
const rewritten = readFileSync(path, "utf8").replace("Appended answer.", "Rewritten answer");
writeFileSync(path, rewritten);
expect(transcriptPage("copilot-transcript", path, { prompts: 1 }).turns).toEqual(parseCopilotTranscript(rewritten).slice(2));
// a newer prompt appended between polls with more than a window after it: its turn is the newest page
const later = recorded.find((record) => record.includes('"user.message"'))!.replace("Recorded fixture text.", "Later prompt.");
appendFileSync(path, [later, picture(3), picture(4)].join("\n") + "\n");
const afterLater = parseCopilotTranscript(readFileSync(path, "utf8"));
expect(transcriptPage("copilot-transcript", path, { prompts: 1 }).turns).toEqual(afterLater.slice(-1));
// a line on disk shaped like the page's own stand-in for a picture is no record: the strict reader refuses it
expect(() => parseCopilotTranscript(`\u0000copilot-set-aside:12\n${recorded[1]}\n`)).toThrow();
} finally { rmSync(dir, { recursive: true, force: true }); }
});
19 changes: 19 additions & 0 deletions server/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ export function copilotRecords(jsonl: string, strict = false, baseOffset?: numbe
for (const [index, line] of lines.entries()) {
const at = offset;
if (baseOffset !== undefined) offset += Buffer.byteLength(line) + 1;
// a checked picture record the newest-turn cache keeps only the length of (copilotSetAside)
const setAside = SET_ASIDE.exec(line);
if (setAside !== null) { offset += Number(setAside[1]) - Buffer.byteLength(line); continue; }
if (!line.trim()) continue;
let value: unknown;
try { value = JSON.parse(line); } catch {
Expand All @@ -61,6 +64,22 @@ export function copilotRecords(jsonl: string, strict = false, baseOffset?: numbe
return records;
}

/** A line copilotSetAside stands in for: the byte length of the record it replaces, behind a mark made for this process that no file holds. */
const SET_ASIDE_MARK = `\u0000copilot-set-aside:${randomBytes(16).toString("hex")}:`;
const SET_ASIDE = new RegExp(`^${SET_ASIDE_MARK}(\\d{1,10})$`);

/**
* A picture record (`session.binary_asset`) a page keeps in memory as its length only, once it has been
* checked whole as the page parser would read it: the parser never reads one, and copilotRecords keeps
* every later record's offset. Any other line, or one that does not parse, is returned as it is.
*/
export function copilotSetAside(line: string): string {
if (!line.startsWith('{"type":"session.binary_asset",')) return line;
let entry: unknown;
try { entry = JSON.parse(line); } catch { return line; }
return row(entry)?.type === "session.binary_asset" && row(row(entry)?.data) !== null ? `${SET_ASIDE_MARK}${Buffer.byteLength(line)}` : line;
}

/** Convert persisted Copilot CLI turns to the shared, read-only chat shape. */
export function parseCopilotTranscript(jsonl: string, maxTurns = MAX_TURNS, baseOffset?: number): ConversationTurn[] {
const turns: ConversationTurn[] = [];
Expand Down
Loading