From e3370ba0d94830542c05ab5ea8cb1a85dfcc222a Mon Sep 17 00:00:00 2001 From: Yu Xie Date: Wed, 21 Oct 2026 23:48:41 +0000 Subject: [PATCH 01/25] feat(chat): read native Copilot CLI transcripts Bind existing Linux panes through the reported UUID and unique PID-held session. Reuse native conversation paging and PTY input, and fall back to Terminal for ambiguous switches. Include parser, contract and browser regressions. Verified by canonical unit/integration gates, native fixture and real Copilot browser smoke tests, and independent GPT-6 Astra review. --- changelog.d/copilot-native-chat.md | 4 + docs/guide.md | 1 + scripts/ci-browser.sh | 1 + scripts/ci-browser.test.ts | 5 +- scripts/copilot-chat-browser-qa.ts | 139 +++++++++++++++++ server/conversation-metadata.ts | 14 ++ server/conversation.ts | 88 +++++++++-- server/copilot-store.ts | 108 +++++++++++++ server/copilot.contract.test.ts | 152 ++++++++++++++++++ server/copilot.test.ts | 239 +++++++++++++++++++++++++++++ server/copilot.ts | 159 +++++++++++++++++++ shared/protocol.ts | 2 +- 12 files changed, 898 insertions(+), 14 deletions(-) create mode 100644 changelog.d/copilot-native-chat.md create mode 100644 scripts/copilot-chat-browser-qa.ts create mode 100644 server/copilot-store.ts create mode 100644 server/copilot.contract.test.ts create mode 100644 server/copilot.test.ts create mode 100644 server/copilot.ts diff --git a/changelog.d/copilot-native-chat.md b/changelog.d/copilot-native-chat.md new file mode 100644 index 0000000000..fd736346b3 --- /dev/null +++ b/changelog.d/copilot-native-chat.md @@ -0,0 +1,4 @@ +### Added +- Copilot CLI conversations can show native text, tool activity and recorded model in chat on Linux + when Herdr's session ID matches the foreground process's sole held session; uncertain identity + or an in-process session switch uses Terminal instead. diff --git a/docs/guide.md b/docs/guide.md index c5965a7541..f8ead969d0 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -155,6 +155,7 @@ Every agent herdr runs shows up with its live status, terminal and alerts. The c | --- | --- | --- | | **Claude Code** | Native transcript, resolved through herdr | ✓ approvals, questions, plan and menu picks, and the `/model` list (picked for the session) | | **Codex** | Native rollout, with tool results and commentary/final phases | ✓ approvals and questions, including queued ones, and the `/model` lists (picked for the session) | +| **Copilot CLI** | Linux native `events.jsonl` when Herdr's Copilot `SessionStart` hook reports an exact UUID and the pane's unique foreground process holds **only that session** in its own `COPILOT_HOME` (otherwise its own `HOME/.copilot`). Follows the existing Codex-style chat presentation for text, tool calls/results and recorded model/effort. In the effective Copilot home (including any `COPILOT_HOME` or `--config-dir`), run `herdr integration install copilot` before starting `copilot` from a Herdr pane's **Terminal**. Ovis neither installs the hook automatically nor changes your home. This native path was verified on Linux with Copilot CLI 1.0.102-1 and Herdr 0.9.3. Herdr may keep the old ID after `/new`, `/clear` or in-process `/resume`; Copilot may also hold older sessions open. If the current session cannot be proved, chat uses Terminal instead of showing a guessed or stale session. Exit Copilot and start a fresh process (resume there if needed) to regain native chat after an ambiguous in-process switch. Missing hooks, ambiguous holds and macOS/Windows use Terminal. This is a reader of existing panes, not a Copilot option in **New workspace**. | — approvals and menus use Terminal | | **omp** | Native session file | ✓ approvals, questions, and the model picker of `/model` and `/switch` (picked for the session: the chat sends `/model` as `/switch`, since omp's own `/model` opens its role hub) | | **omo** | Native session file, found through the pane's process tree | ✓ the `/model` list (picked for the session); other menus — use Terminal | | **gjc** | Native session file identified by GJC's SDK broker and the pane's process | — use Terminal | diff --git a/scripts/ci-browser.sh b/scripts/ci-browser.sh index 13bbc36e57..711b493c9f 100644 --- a/scripts/ci-browser.sh +++ b/scripts/ci-browser.sh @@ -80,6 +80,7 @@ run_script scripts/chat-scroll-padding-regression.ts run_script scripts/chat-selection-copy-regression.ts run_script scripts/chat-fallback-browser-qa.ts run_script scripts/grok-chat-browser-qa.ts +run_script scripts/copilot-chat-browser-qa.ts run_script scripts/omo-chat-browser-qa.ts run_script scripts/omo-plan-browser-regression.ts run_script scripts/agent-skill-menu-demo-regression.ts diff --git a/scripts/ci-browser.test.ts b/scripts/ci-browser.test.ts index 10f65b0fb1..af000a5a29 100644 --- a/scripts/ci-browser.test.ts +++ b/scripts/ci-browser.test.ts @@ -70,9 +70,12 @@ describe("browser lane", () => { it("summarizes every script and exits zero on success without enabling local evidence", async () => { const { text, code, evidence } = await lane(); expect(code).toBe(0); - expect(rows(text)).toHaveLength(81); + expect(rows(text)).toHaveLength(82); expect(rows(text).map((row) => row.script)).toContain("scripts/replay-clipboard-demo-regression.ts"); expect(rows(text).map((row) => row.script)).toContain("scripts/theme-first-paint-demo-regression.ts"); + expect(rows(text).map((row) => row.script)).toContain("scripts/copilot-chat-browser-qa.ts"); + expect(rows(text).map((row) => row.script)).toContain("scripts/attention-jump-demo-regression.ts"); + expect(rows(text).map((row) => row.script)).toContain("scripts/tool-copy-demo-regression.ts"); expect(rows(text).map((row) => row.script)).toContain("scripts/opencode-prompt-regression.ts"); expect(rows(text).map((row) => row.script)).toContain("scripts/session-history-browser-qa.ts"); expect(rows(text).map((row) => row.script)).toContain("scripts/command-outcome-demo-regression.ts"); diff --git a/scripts/copilot-chat-browser-qa.ts b/scripts/copilot-chat-browser-qa.ts new file mode 100644 index 0000000000..e14993e067 --- /dev/null +++ b/scripts/copilot-chat-browser-qa.ts @@ -0,0 +1,139 @@ +/** Owned native Copilot-shaped fixture -> Herdr -> HTTP -> built React chat. */ +import "./test-herdr.ts"; +import assert from "node:assert/strict"; +import { appendFileSync, chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { chromium } from "playwright-core"; +import { createServer } from "../server/index.ts"; +import { herdrRpc, sessionSnapshot, workspaceClose, workspaceCreate } from "../server/herdr/client.ts"; +import { chromePath } from "./chrome-path.ts"; + +if (process.platform !== "linux") { + console.log("SKIP native Copilot store requires Linux process descriptors"); + process.exit(0); +} +const root = mkdtempSync(join(tmpdir(), "herdr-copilot-browser-")); +const home = join(root, "copilot-home"); +const id = "12aa83d8-48bc-41f2-a498-7055c5c44a01"; +const directory = join(home, "session-state", id); +const transcript = join(directory, "events.jsonl"); +const executable = join(root, "copilot.js"); +const input = join(root, "received.txt"); +const event = (type: string, data: object) => JSON.stringify({ + type, data, id: crypto.randomUUID(), timestamp: new Date().toISOString(), parentId: null, +}) + "\n"; +const output = "fixture output ".repeat(430) + "COPILOT_OUTPUT_END"; +let workspace: string | undefined; +let server: ReturnType | undefined; +let browser: Awaited> | undefined; +try { + mkdirSync(directory, { recursive: true }); + writeFileSync(input, ""); + writeFileSync(transcript, event("session.start", { sessionId: id, copilotVersion: "1.0.102-1" }) + + event("user.message", { content: "A native Copilot prompt", transformedContent: "not visible", parentAgentTaskId: "root-task-id" }) + + event("assistant.message", { content: "Looking up the answer.", phase: "commentary", model: "fixture-model", + toolRequests: [{ toolCallId: "fixture-call", name: "shell", arguments: { command: "pwd" }, toolTitle: "Check folder" }] }) + + event("tool.execution_start", { toolCallId: "fixture-call", toolName: "shell", arguments: { command: "pwd" } }) + + event("tool.execution_complete", { toolCallId: "fixture-call", success: true, result: { content: output } }) + + event("assistant.message", { content: "Native Copilot answer.", phase: "final_answer", model: "fixture-model" })); + // A Node script named copilot is a genuine foreground process with a PID-held native + // marker; its stdin remains the pane's PTY so the regular composer can reach it. + writeFileSync(executable, `import { appendFileSync, mkdirSync, openSync } from "node:fs"; +import { join } from "node:path"; +const home = process.env.COPILOT_HOME; +const marker = join(home, "session-state", process.argv[2], \`inuse.\${process.pid}.hold\`); +mkdirSync(join(home, "session-state", process.argv[2]), { recursive: true }); +openSync(marker, "w"); +process.stdin.setEncoding("utf8"); +let buffer = ""; +process.stdin.on("data", (chunk) => { + buffer += chunk; + for (let at; (at = buffer.indexOf("\\n")) !== -1;) { + appendFileSync(process.argv[3], buffer.slice(0, at) + "\\n"); + buffer = buffer.slice(at + 1); + } +}); +console.log("fixture ready");`); + chmodSync(executable, 0o755); + const created = await workspaceCreate({ cwd: root, label: "herdr-web-ui-test-copilot-browser" }); + workspace = created.workspace.workspace_id; + const pane = created.root_pane.pane_id; + await herdrRpc("pane.send_text", { pane_id: pane, text: `COPILOT_HOME=${home} node ${executable} ${id} ${input}\n` }); + for (const deadline = Date.now() + 10_000;;) { + if ((await sessionSnapshot()).panes.find((candidate) => candidate.pane_id === pane)?.agent === "copilot") break; + if (Date.now() > deadline) throw new Error("owned Copilot fixture process was not detected"); + await Bun.sleep(50); + } + await herdrRpc("pane.report_agent_session", { pane_id: pane, agent: "copilot", source: "herdr:copilot", + agent_session_id: id, seq: Date.now() * 1000 }); + server = createServer({ hostname: "127.0.0.1", port: 0, token: "", stateDir: join(root, "state"), tailscaleOwner: null }); + const endpoint = `http://127.0.0.1:${server.port}/api/pane/conversation?pane_id=${encodeURIComponent(pane)}`; + const body = await (await fetch(endpoint)).json() as { source: string }; + assert.equal(body.source, "copilot-transcript"); + + browser = await chromium.launch({ executablePath: chromePath(), headless: true, args: ["--no-sandbox"] }); + const page = await browser.newPage({ viewport: { width: 1100, height: 800 }, locale: "en-US" }); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.setDefaultTimeout(15_000); + await page.addInitScript((paneId) => { + localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en" })); + localStorage.setItem(`herdr-web-ui:view:${paneId}`, "chat"); + }, pane); + await page.goto(`http://127.0.0.1:${server.port}/?pane=${encodeURIComponent(pane)}`); + const log = page.locator(`.pane-frame[data-layout-pane="${pane}"]`).getByRole("log"); + await log.getByText("Native Copilot answer.", { exact: true }).waitFor(); + await log.getByText("A native Copilot prompt", { exact: true }).waitFor(); + assert.equal((await log.innerText()).includes("not visible"), false); + const head = log.locator(".work-block-head"); + if (await head.getAttribute("aria-expanded") !== "true") await head.click(); + await log.locator(".work-row-head").click(); + await log.locator(".chat-tool-more").click(); + await log.getByText(/COPILOT_OUTPUT_END/).waitFor(); + appendFileSync(transcript, event("session.resume", { selectedModel: "fixture-model" }) + + event("user.message", { content: "Follow-up prompt" }) + + event("assistant.message", { content: "Follow-up answer.", phase: "final_answer", model: "fixture-model" })); + await log.getByText("Follow-up answer.", { exact: true }).waitFor(); + // Capture readable conversation, not a 6 KiB expanded tool detail. + if (await head.getAttribute("aria-expanded") === "true") await head.click(); + if (process.env.UI_EVIDENCE_DIR) { + mkdirSync(process.env.UI_EVIDENCE_DIR, { recursive: true }); + await page.screenshot({ path: join(process.env.UI_EVIDENCE_DIR, "copilot-native-desktop.png"), animations: "disabled" }); + } + for (const viewport of [{ width: 1100, height: 800 }, { width: 390, height: 844 }]) { + await page.setViewportSize(viewport); + if (viewport.width === 390) { + await page.locator("#workspace-drawer").waitFor({ state: "hidden" }); + await page.waitForFunction(() => { + const drawer = document.querySelector("#workspace-drawer"); + return drawer === null || drawer.getBoundingClientRect().right <= 0 || getComputedStyle(drawer).display === "none"; + }); + } + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, "document must fit viewport"); + assert.equal(await log.evaluate((node) => node.scrollWidth <= node.clientWidth), true, "chat must not overflow horizontally"); + } + if (process.env.UI_EVIDENCE_DIR) { + mkdirSync(process.env.UI_EVIDENCE_DIR, { recursive: true }); + await page.screenshot({ path: join(process.env.UI_EVIDENCE_DIR, "copilot-native-mobile.png"), animations: "disabled" }); + } + await page.getByRole("textbox", { name: "Message", exact: true }).fill("Fixture composer send"); + await page.getByRole("button", { name: "Send message", exact: true }).click(); + for (const deadline = Date.now() + 10_000;;) { + if (readFileSync(input, "utf8").includes("Fixture composer send")) break; + if (Date.now() > deadline) throw new Error("composer text never reached owned Copilot fixture PTY"); + await Bun.sleep(50); + } + assert.deepEqual(errors, []); + console.log("PASS Copilot native HTTP/React, tool detail, append, desktop/mobile and PTY send"); +} finally { + const failures: unknown[] = []; + try { await browser?.close(); } catch (error) { failures.push(error); } + try { server?.stop(); } catch (error) { failures.push(error); } + try { if (workspace) await workspaceClose(workspace); } catch (error) { failures.push(error); } + try { rmSync(root, { recursive: true, force: true }); } catch (error) { failures.push(error); } + if (failures.length) { + console.error(new AggregateError(failures, "Copilot browser fixture cleanup failed")); + process.exitCode = 1; + } +} diff --git a/server/conversation-metadata.ts b/server/conversation-metadata.ts index d8c62cb936..8dd454cc35 100644 --- a/server/conversation-metadata.ts +++ b/server/conversation-metadata.ts @@ -1,6 +1,7 @@ import { isContextClear, piMessage } from "./transcript-records.ts"; import { piContextWindow } from "./pi-models.ts"; import { cmdContextWindow } from "./cmd.ts"; +import { copilotRecords } from "./copilot.ts"; import type { ConversationMetadata, ConversationResponse } from "../shared/protocol.ts"; type RecordValue = Record; @@ -39,6 +40,19 @@ export function parseConversationMetadata(text: string, source: ConversationResp const metadata: ConversationMetadata = before ? { ...before } : { model: null, reasoning_effort: null }; // Agy's exported rows do not attest the selected model or reasoning effort. if (source === "agy-transcript") return metadata; + if (source === "copilot-transcript") { + for (const { type, data } of copilotRecords(text)) { + const model = type === "session.start" ? label(data.selectedModel) + : type === "session.model_change" ? label(data.newModel) + : type === "session.resume" ? label(data.selectedModel) + : type === "assistant.message" ? label(data.model) : null; + if (model) metadata.model = model; + if (type === "session.start" || type === "session.model_change" || type === "session.resume") { + if ("reasoningEffort" in data) metadata.reasoning_effort = label(data.reasoningEffort); + } + } + return metadata; + } // pi states the provider on the same records that state usage, so the window is asked for // from what this line says rather than remembered across sources let piProvider: string | null = null; diff --git a/server/conversation.ts b/server/conversation.ts index 6b1aeed761..b48bda123a 100644 --- a/server/conversation.ts +++ b/server/conversation.ts @@ -34,11 +34,11 @@ */ import { createHash, randomUUID } from "node:crypto"; -import { closeSync, openSync, readFileSync, readSync, realpathSync, statSync } from "node:fs"; +import { closeSync, fstatSync, openSync, readFileSync, readSync, realpathSync, statSync } from "node:fs"; import nodePath, { type PlatformPath } from "node:path"; import type { ConversationMetadata, ConversationPart, ConversationTurn, HerdrPane, SessionSnapshot } from "../shared/protocol.ts"; -import { herdrRpc, paneRead, sessionSnapshot, type ProcessInfo } from "./herdr/client.ts"; +import { herdrRpc, paneProcessInfo, paneRead, sessionSnapshot, type ProcessInfo } from "./herdr/client.ts"; import { resolveAgent } from "./pane-context.ts"; import { codexHistorySegments, createCodexTranscriptParser, codexOutputText, codexTranscriptPath, defaultCodexHome, forgetAllCodexState, forgetCodexStateFor, paneCodexHome, parseCodexTranscript, readRange } from "./codex.ts"; import { CODEX_IMAGE_REF, codexTranscriptImage } from "./codex-images.ts"; @@ -58,6 +58,8 @@ 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 { trimOutput } from "./tool-output.ts"; +import { copilotOutput, copilotRootRecord, parseCopilotTranscript } from "./copilot.ts"; +import { copilotTranscriptFile, openCopilotFile } from "./copilot-store.ts"; import { parseConversationMetadata } from "./conversation-metadata.ts"; import { forgetHermesTranscriptState, hermesConversationPage, hermesToolOutput, hermesTranscriptForPane } from "./hermes.ts"; @@ -541,7 +543,7 @@ export class HistoryChanged extends Error { /** What paneConversation resolved: which store the turns came from, and where they start. */ export type RecognizedConversation = { activity?: "thinking" | "working" | "idle"; - source: "grok-transcript" | "agy-transcript" | "claude-transcript" | "omp-transcript" | "omo-transcript" | "gjc-transcript" | "pi-transcript" | "cmd-transcript" | "codex-transcript" | "devin-transcript" | "opencode-transcript" | "hermes-transcript"; + source: "copilot-transcript" | "grok-transcript" | "agy-transcript" | "claude-transcript" | "omp-transcript" | "omo-transcript" | "gjc-transcript" | "pi-transcript" | "cmd-transcript" | "codex-transcript" | "devin-transcript" | "opencode-transcript" | "hermes-transcript"; turns: ConversationTurn[]; metadata: ConversationMetadata; /** `metadata` was read off the agent's own screen (ConversationResponse.screen) */ @@ -760,11 +762,12 @@ function followClaudeBranch(branch: ClaudeBranch, line: Buffer, start: number, e /** Reset markers are small native control records. Scan each appended byte once, * in bounded chunks; retain offsets, never a session-sized string. - * The same pass keeps an omp-family transcript's latest model and thinking-level - * records (small lines): a change between the metadata head and the newest page - * is otherwise never read, and the chat showed the level the session started at. */ + * The same pass keeps the latest provider-specific metadata controls (small lines): + * changes between the head and newest page are otherwise never read. */ const SETTING_TYPES = ['"model_change"', '"thinking_level_change"']; -const clearScans = new Map>; branch?: ClaudeBranch }>(); +const COPILOT_SETTING_TYPES = ['"session.model_change"', '"session.resume"']; +type SettingType = "model_change" | "thinking_level_change" | "copilot_model" | "copilot_effort"; +const clearScans = new Map>; branch?: ClaudeBranch }>(); function applyHistoryBoundary(path: string, stream: TranscriptStream, source: RecognizedConversation["source"]): void { if (source === "codex-transcript" || source === "grok-transcript" || source === "agy-transcript") return; let scan = clearScans.get(path); @@ -787,10 +790,24 @@ function applyHistoryBoundary(path: string, stream: TranscriptStream, source: Re for (let newline = bytes.indexOf(0x0a); newline !== -1; newline = bytes.indexOf(0x0a, offset)) { const line = bytes.subarray(offset, newline); if (!skipping && isClear(line)) scan.floor = base + offset; - else if (!skipping && source !== "claude-transcript" && SETTING_TYPES.some((type) => line.includes(type))) { + else if (!skipping && source !== "claude-transcript" && (source === "copilot-transcript" ? COPILOT_SETTING_TYPES : SETTING_TYPES).some((type) => line.includes(type))) { try { - const type: unknown = JSON.parse(line.toString("utf8"))?.type; - if (type === "model_change" || type === "thinking_level_change") scan.settings[type] = { offset: base + offset, end: base + newline, line: line.toString("utf8") }; + const entry: unknown = JSON.parse(line.toString("utf8")); + const type = entry !== null && typeof entry === "object" && "type" in entry ? entry.type : null; + if (source === "copilot-transcript") { + if ((type === "session.model_change" || type === "session.resume") && copilotRootRecord(entry)) { + const data = entry !== null && typeof entry === "object" && "data" in entry ? entry.data : null; + if (data !== null && typeof data === "object" && !Array.isArray(data)) { + const model = type === "session.model_change" ? "newModel" in data ? data.newModel : null + : "selectedModel" in data ? data.selectedModel : null; + const setting = { offset: base + offset, end: base + newline, line: line.toString("utf8") }; + if (typeof model === "string" && model.trim() && !model.startsWith("<")) scan.settings.copilot_model = setting; + if ("reasoningEffort" in data) scan.settings.copilot_effort = setting; + } + } + } else if (type === "model_change" || type === "thinking_level_change") { + scan.settings[type] = { offset: base + offset, end: base + newline, line: line.toString("utf8") }; + } } catch { /* not a record */ } } if (branch) { @@ -824,6 +841,7 @@ function applyHistoryBoundary(path: string, stream: TranscriptStream, source: Re /** the mark of another session's message in a Claude record, in either of its forms (claudePeerNotice) */ const PEER_MARK = Buffer.from('"kind":"peer"'); const TURN_MARK: Record = { + "copilot-transcript": Buffer.from('"user.message"'), "grok-transcript": Buffer.from('"user_message_chunk"'), "agy-transcript": Buffer.from('"USER_INPUT"'), "codex-transcript": Buffer.from('"task_started"'), @@ -842,9 +860,11 @@ const TURN_MARK: Record = { * and the same for pi, whose prompts are `message` records with a user role. */ function opensTurn(source: StreamSource, line: string): boolean { - let entry: { type?: unknown; isMeta?: unknown; isCompactSummary?: unknown; payload?: { type?: unknown }; message?: { role?: unknown; content?: unknown } }; + let entry: { type?: unknown; agentId?: unknown; data?: { content?: unknown; parentAgentTaskId?: unknown; agentId?: unknown }; isMeta?: unknown; isCompactSummary?: unknown; payload?: { type?: unknown }; message?: { role?: unknown; content?: unknown } }; try { entry = JSON.parse(line); } catch { return false; } if (entry === null || typeof entry !== "object") return false; + if (source === "copilot-transcript") return entry.type === "user.message" && copilotRootRecord(entry) + && typeof entry.data?.content === "string" && !!entry.data.content.trim(); if (source === "codex-transcript") return entry.type === "event_msg" && entry.payload?.type === "task_started"; if (source !== "claude-transcript") { const message = piMessage(entry); @@ -1070,6 +1090,7 @@ function subagentsOnPage(path: string, text: string): ReadonlyMap, noticed?: Set): ConversationTurn[] { + if (source === "copilot-transcript") return parseCopilotTranscript(text, Infinity); if (source === "grok-transcript") { try { return parseGrokTranscript(text, path); } catch (error) { if (error instanceof GrokUnavailable) throw new ConversationUnavailable("transcript_missing"); @@ -1187,6 +1208,8 @@ function metadataHead(path: string, stream: TranscriptStream, source: Recognized // a record the head cuts in two is read whole here .filter((setting) => setting.end > end && setting.offset < start) .sort((a, b) => a.offset - b.offset) + // One control may update both fields; replay the native line once. + .filter((setting, index, settings) => index === 0 || setting.offset !== settings[index - 1]!.offset) .map((setting) => setting.line); return [readStream(stream, stream.floor, end).toString("utf8"), ...later].join("\n"); } @@ -1392,7 +1415,7 @@ export async function paneProcessRoots(paneId: string, agent: string | null | un ...(typeof candidate.name === "string" ? { name: candidate.name } : {}), cwd: typeof candidate.cwd === "string" ? candidate.cwd : "", })); - const matched = agent && ["claude", "codex", "omp", "gjc", "pi", "opencode", "devin", "grok", "hermes", "agy"].includes(agent) + const matched = agent && ["claude", "codex", "omp", "gjc", "pi", "opencode", "devin", "grok", "hermes", "agy", "copilot"].includes(agent) ? agentProcesses(foreground, agent)[0] ?? null : null; for (const process of foreground) { const identity = omoProcessIdentity(process, "", home); @@ -1536,6 +1559,7 @@ async function ompTranscriptPath(paneId: string, cwd: string): Promise { if (held === null) throw new ConversationUnavailable("no_session_path"); return held; } + // omp names its session file at start and writes it with the first answer const unwritten = unwrittenSession(path, store); if (unwritten !== null) throw new ConversationNotStarted(unwritten.id, "omp-transcript", unwritten.path); @@ -1545,6 +1569,26 @@ async function ompTranscriptPath(paneId: string, cwd: string): Promise { return inStore; } +/** Herdr's SessionStart hook owns the identity; argv and directory recency never do. */ +async function copilotTranscriptPath(pane: HerdrPane, panes: readonly HerdrPane[]): Promise { + const report = pane.agent_session; + if (pane.agent !== "copilot" || report?.agent !== "copilot" || report.kind !== "id" + || report.source !== "herdr:copilot" || typeof report.value !== "string" || !SESSION_ID.test(report.value) + || panes.some((peer) => peer.pane_id !== pane.pane_id && peer.agent_session?.agent === "copilot" + && peer.agent_session.kind === "id" && peer.agent_session.value === report.value)) { + throw new ConversationUnavailable("no_session_id"); + } + const info = await paneProcessInfo(pane.pane_id).catch(() => null); + const processes = agentProcesses(info?.foreground_processes ?? [], "copilot"); + const foreground = processes[0]; + if (process.platform !== "linux" || processes.length !== 1 || typeof foreground?.pid !== "number") { + throw new ConversationUnavailable("no_session_path"); + } + const path = copilotTranscriptFile(report.value, processEnviron(foreground.pid), foreground.argv ?? [], foreground.pid); + if (path === null) throw new ConversationUnavailable("no_session_path"); + return path; +} + /** Where a pane's conversation is: a transcript file, or a session in OpenCode's database. */ type ResolvedTranscript = | { source: StreamSource; path: string; codexHome?: string; agy?: AgyStore } @@ -1587,6 +1631,7 @@ async function resolveTranscript(pane: HerdrPane, cwd: string, codexHome?: strin // session headers name the one they run in const sessionCwd = typeof pane.foreground_cwd === "string" && pane.foreground_cwd.length > 0 ? pane.foreground_cwd : cwd; try { + if (agent === "copilot") return { source: "copilot-transcript", path: await copilotTranscriptPath(pane, panes ?? [pane]) }; if (agent === "grok") { const found = await resolveGrokStore(pane, grokHome); if (!found) throw new ConversationUnavailable("no_session_path"); @@ -1780,6 +1825,7 @@ export async function paneConversation(paneId: string, codexHome?: string, page: if (!(error instanceof ConversationUnavailable)) throw error; const pane = snapshot.panes.find((candidate) => candidate.pane_id === paneId); if (pane === undefined) { launchedAgents.forget(paneId); throw error; } + if (pane.agent === "copilot" && (page.before !== undefined || page.since !== undefined || page.from !== undefined)) throw new HistoryChanged(); // an agent this server started writes no session before its first message (launched-agents.ts) const launch = launchedAgents.notBegun(paneId, pane.agent_status); if (launch === null) { @@ -1940,6 +1986,13 @@ export async function readCommandTranscript(paneId: string, codexHome?: string): /** One page of a resolved transcript (paneConversation's `page`). */ export function transcriptPage(source: StreamSource, path: string, page: ConversationPage = {}, codexHome?: string): RecognizedConversation { + if (source === "copilot-transcript") { + let fd: number; + try { fd = openCopilotFile(path); } + catch { throw new ConversationUnavailable("transcript_missing"); } + try { return transcriptPageRead(source, path, page, codexHome, undefined, fd); } + finally { closeSync(fd); } + } if (source === "agy-transcript") { try { return withAgyHistory(path, (projection, fd) => ({ ...transcriptPageRead(source, path, page, codexHome, projection, fd), activity: projection.activity })); } catch (error) { @@ -1954,6 +2007,10 @@ function transcriptPageRead(source: StreamSource, path: string, page: Conversati let stat: { dev: number; ino: number; size: number; mtimeMs: number; ctimeMs: number }; try { stat = statSync(path); + if (fd !== undefined) { + const pinned = fstatSync(fd); + if (pinned.dev !== stat.dev || pinned.ino !== stat.ino || pinned.size !== stat.size) throw new ConversationUnavailable("transcript_missing"); + } } catch { throw new ConversationUnavailable("transcript_missing"); } @@ -2120,6 +2177,13 @@ export function transcriptToolOutput(source: StreamSource, path: string, ref: st if (source === "grok-transcript") return grokToolOutput(path, ref); if (source === "agy-transcript") return agyToolOutput(path, ref); if (source === "pi-transcript" || source === "cmd-transcript") return piToolOutput(path, ref); + if (source === "copilot-transcript") { + let fd: number; + try { fd = openCopilotFile(path); } catch { return null; } + try { + return copilotOutput(fd, ref, TOOL_OUTPUT_MAX); + } finally { closeSync(fd); } + } let text: string; try { text = readFileSync(path, "utf8"); } catch { return null; } return outputInText(source, text, ref); diff --git a/server/copilot-store.ts b/server/copilot-store.ts new file mode 100644 index 0000000000..9361c8861b --- /dev/null +++ b/server/copilot-store.ts @@ -0,0 +1,108 @@ +import { constants, closeSync, fstatSync, openSync, opendirSync, readlinkSync, readSync, realpathSync, statSync } from "node:fs"; +import { basename, dirname, isAbsolute, join, relative, sep } from "node:path"; + +const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const HEADER_BYTES = 64 * 1024; +const MAX_DESCRIPTORS = 4096; + +/** Pin one regular canonical file; callers must close the descriptor after reading. */ +export function openCopilotFile(file: string): number { + if (realpathSync(file) !== file) throw new Error("Copilot transcript path is not canonical"); + const fd = openSync(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0)); + try { + const stat = fstatSync(fd); + const pathStat = statSync(file); + if (!stat.isFile() || pathStat.dev !== stat.dev || pathStat.ino !== stat.ino) { + throw new Error("Copilot transcript is not a regular canonical file"); + } + const session = basename(dirname(file)); + if (SESSION_ID.test(session)) { + const bytes = Buffer.alloc(Math.min(stat.size, HEADER_BYTES)); + const length = readSync(fd, bytes, 0, bytes.length, 0); + const newline = bytes.indexOf(0x0a); + if (newline < 0 || newline >= length) throw new Error("Copilot transcript header is incomplete"); + const header = JSON.parse(bytes.subarray(0, newline).toString("utf8")) as { type?: unknown; data?: { sessionId?: unknown } }; + if (header?.type !== "session.start" || header.data?.sessionId !== session) throw new Error("Copilot transcript header does not match its session"); + } + return fd; + } catch (error) { + closeSync(fd); + throw error; + } +} + +/** Only an actual PID-owned, open and canonical marker proves this process still holds a session. */ +function scanHeldSessions(pid: number, store: string): Set | null | undefined { + const sessions = new Set(); + const directory = opendirSync(`/proc/${pid}/fd`); + let count = 0; + try { + for (let fd = directory.readSync(); fd; fd = directory.readSync()) { + if (++count > MAX_DESCRIPTORS) return null; + let target: string; + try { target = readlinkSync(`/proc/${pid}/fd/${fd.name}`); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + return null; + } + const path = relative(store, target); + if (path === ".." || path.startsWith(`..${sep}`) || isAbsolute(path)) continue; + const parts = path.split(sep); + if (parts.length !== 2 || !SESSION_ID.test(parts[0]!) || parts[1] !== `inuse.${pid}.hold`) { + if (target.includes("inuse.") || target.includes(".hold")) return null; + continue; + } + if (realpathSync(join(store, parts[0]!)) !== join(store, parts[0]!) + || realpathSync(target) !== target) return null; + const marker = statSync(target); + const open = statSync(`/proc/${pid}/fd/${fd.name}`); + if (!marker.isFile() || !open.isFile() || marker.dev !== open.dev || marker.ino !== open.ino) return null; + sessions.add(parts[0]!); + } + } finally { directory.closeSync(); } + return sessions; +} + +/** A disappearing descriptor invalidates that scan; retry once, then fall back. */ +export function heldSessions(pid: number, store: string): Set | null { + for (let attempt = 0; attempt < 2; attempt++) { + const sessions = scanHeldSessions(pid, store); + if (sessions !== undefined) return sessions; + } + return null; +} + +/** Only the reported session, beneath the foreground process's own Copilot home. */ +export function copilotTranscriptFile(session: unknown, environment: readonly string[] | null, argv: readonly string[], pid: number): string | null { + if (typeof session !== "string" || !SESSION_ID.test(session) || environment === null || !Number.isSafeInteger(pid) || pid < 1) return null; + const values = (key: string) => environment.filter((item) => item.startsWith(`${key}=`)).map((item) => item.slice(key.length + 1)); + const home = values("HOME"); + const explicit = values("COPILOT_HOME"); + if (home.length > 1 || explicit.length > 1) return null; + const config: string[] = []; + for (let i = 1; i < argv.length; i++) { + const option = argv[i]!; + if (option === "--") break; + if (option.startsWith("--config-dir=")) config.push(option.slice("--config-dir=".length)); + else if (option === "--config-dir") config.push(argv[++i] ?? ""); + } + if (config.length > 1) return null; + const chosen = config[0] ?? explicit[0] ?? (home[0] ? join(home[0], ".copilot") : ""); + if (!isAbsolute(chosen)) return null; + try { + const canonicalHome = realpathSync(chosen); + if (!statSync(canonicalHome).isDirectory()) return null; + const store = join(canonicalHome, "session-state"); + if (realpathSync(store) !== store) return null; + const held = heldSessions(pid, store); + if (held === null || held.size !== 1 || !held.has(session)) return null; + const directory = join(store, session); + if (realpathSync(directory) !== directory) return null; + const file = join(directory, "events.jsonl"); + if (realpathSync(file) !== file) return null; + const fd = openCopilotFile(file); + try { + return file; + } finally { closeSync(fd); } + } catch { return null; } +} diff --git a/server/copilot.contract.test.ts b/server/copilot.contract.test.ts new file mode 100644 index 0000000000..fe6df9fbc5 --- /dev/null +++ b/server/copilot.contract.test.ts @@ -0,0 +1,152 @@ +import { afterAll, beforeAll, expect, it } from "bun:test"; +import { appendFileSync, chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createServer } from "./index.ts"; +import { herdrRpc, sessionSnapshot, workspaceClose, workspaceCreate } from "./herdr/client.ts"; +import { forgetTranscriptState } from "./conversation.ts"; + +const id = "12aa83d8-48bc-41f2-a498-7055c5c44a01"; +const other = "12aa83d8-48bc-41f2-a498-7055c5c44a02"; +const root = mkdtempSync(join(tmpdir(), "herdr-copilot-contract-")); +const home = join(root, "custom-home"); +const executable = join(root, "copilot.js"); +const file = (session: string) => join(home, "session-state", session, "events.jsonl"); +const event = (type: string, data: object) => JSON.stringify({ type, data, id: crypto.randomUUID(), timestamp: "2026-10-21T00:00:00Z", parentId: null }) + "\n"; +const fixture = (session: string, content: string) => + event("session.start", { sessionId: session, copilotVersion: "1.0.102-1" }) + + event("user.message", { content, transformedContent: "not shown", parentAgentTaskId: "root-task-id" }) + + event("assistant.message", { content: "fixture response", phase: "final_answer", model: "fixture-model" }); +let app: ReturnType; +let workspace: string; +let pane: string; + +beforeAll(async () => { + for (const session of [id, other]) mkdirSync(join(home, "session-state", session), { recursive: true }); + writeFileSync(file(id), fixture(id, "session A")); + writeFileSync(executable, `import { openSync, mkdirSync, writeFileSync, appendFileSync } from "node:fs"; +import { join } from "node:path"; +const home = process.env.COPILOT_HOME; +const hold = (id) => { const dir = join(home, "session-state", id); mkdirSync(dir, { recursive: true }); openSync(join(dir, \`inuse.\${process.pid}.hold\`), "w"); }; +hold(process.argv[2]); +process.stdin.setEncoding("utf8"); +let buffer = ""; +process.stdin.on("data", (chunk) => { + buffer += chunk; + for (let at; (at = buffer.indexOf("\\n")) !== -1;) { + const line = buffer.slice(0, at); buffer = buffer.slice(at + 1); + if (line.startsWith(":hold ")) hold(line.slice(6)); + else if (line === ":quit") process.exit(0); + else appendFileSync(join(home, "input.txt"), line + "\\n"); + } +}); +console.log("fixture ready");`); + chmodSync(executable, 0o755); + const created = await workspaceCreate({ cwd: root, label: "herdr-web-ui-test-copilot-api" }); + workspace = created.workspace.workspace_id; + pane = created.root_pane.pane_id; + await herdrRpc("pane.send_text", { pane_id: pane, text: `COPILOT_HOME=${home} node ${executable} ${id}\n` }); + for (const deadline = Date.now() + 10_000;;) { + if ((await sessionSnapshot()).panes.find((p) => p.pane_id === pane)?.agent === "copilot") break; + if (Date.now() > deadline) throw new Error("isolated Copilot stand-in not detected by Herdr"); + await Bun.sleep(50); + } + app = createServer({ hostname: "127.0.0.1", port: 0, token: "", stateDir: join(root, "state"), tailscaleOwner: null }); +}); + +afterAll(async () => { + app?.stop(); + if (workspace) await workspaceClose(workspace); + forgetTranscriptState(); + rmSync(root, { recursive: true, force: true }); +}); + +it.skipIf(process.platform !== "linux")("binds only the reported UUID to the foreground process's home and rejects old cursors after a switch", async () => { + const url = `http://127.0.0.1:${app.port}/api/pane/conversation?pane_id=${encodeURIComponent(pane)}`; + const read = async () => (await fetch(url)).json() as Promise<{ source: string; turns: { parts: { text?: string }[] }[]; cursor?: string; history_id?: string }>; + expect((await read()).source).toBe("scrollback"); + const report = async (session: string, seq: number, source = "herdr:copilot", paneId = pane) => { + await herdrRpc("pane.report_agent_session", { pane_id: paneId, agent: "copilot", source, seq: Date.now() * 1000 + seq, agent_session_id: session }); + }; + await report(id, 1, "untrusted"); + expect((await read()).source).toBe("scrollback"); + await report(id, 2); + const first = await read(); + expect(first.source).toBe("copilot-transcript"); + expect(first.turns[0]?.parts[0]?.text).toBe("session A"); + appendFileSync(file(id), event("assistant.message", { content: "Checking.", phase: "commentary", + toolRequests: [{ toolCallId: "call-1", name: "shell", arguments: { command: "pwd" } }], model: "fixture-model" })); + const toolUrl = `http://127.0.0.1:${app.port}/api/pane/conversation/tool-output?${new URLSearchParams({ pane_id: pane, ref: "call-1" })}`; + expect((await read()).turns.at(-1)?.parts.at(-1)).toMatchObject({ kind: "tool", output: "" }); + appendFileSync(file(id), event("tool.execution_start", { toolCallId: "call-1", toolName: "shell" }) + + event("tool.execution_complete", { toolCallId: "call-1", success: false, result: { content: "error ".repeat(900) } })); + expect((await read()).turns.at(-1)?.parts.at(-1)).toMatchObject({ kind: "tool", error: true, output_ref: "call-1" }); + expect(await (await fetch(toolUrl)).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); + expect((await read()).turns.at(-1)?.parts[0]?.text).toBe("appended"); + writeFileSync(file(other), fixture(other, "session B")); + const second = await workspaceCreate({ cwd: root, label: "herdr-web-ui-test-copilot-peer" }); + try { + const peer = second.root_pane.pane_id; + await herdrRpc("pane.send_text", { pane_id: peer, text: `COPILOT_HOME=${home} node ${executable} ${other}\n` }); + for (const deadline = Date.now() + 10_000;;) { + if ((await sessionSnapshot()).panes.find((p) => p.pane_id === peer)?.agent === "copilot") break; + if (Date.now() > deadline) throw new Error("peer Copilot process was not detected"); + await Bun.sleep(50); + } + await report(other, 1, "herdr:copilot", peer); + const peerUrl = `http://127.0.0.1:${app.port}/api/pane/conversation?pane_id=${encodeURIComponent(peer)}`; + expect((await (await fetch(peerUrl)).json()).turns[0].parts[0].text).toBe("session B"); + expect((await read()).turns[0]?.parts[0]?.text).toBe("session A"); + const duplicate = await workspaceCreate({ cwd: root, label: "herdr-web-ui-test-copilot-duplicate" }); + try { + const claimant = duplicate.root_pane.pane_id; + await herdrRpc("pane.send_text", { pane_id: claimant, text: `COPILOT_HOME=${home} node ${executable} ${id}\n` }); + for (const deadline = Date.now() + 10_000;;) { + if ((await sessionSnapshot()).panes.find((p) => p.pane_id === claimant)?.agent === "copilot") break; + if (Date.now() > deadline) throw new Error("duplicate claimant was not detected"); + await Bun.sleep(50); + } + await report(id, 1, "herdr:copilot", claimant); + expect((await read()).source).toBe("scrollback"); + const claimantUrl = `http://127.0.0.1:${app.port}/api/pane/conversation?pane_id=${encodeURIComponent(claimant)}`; + expect((await (await fetch(claimantUrl)).json()).source).toBe("scrollback"); + expect((await (await fetch(peerUrl)).json()).source).toBe("copilot-transcript"); + } finally { await workspaceClose(duplicate.workspace.workspace_id); } + expect((await read()).source).toBe("copilot-transcript"); + } finally { await workspaceClose(second.workspace.workspace_id); } + await herdrRpc("pane.send_text", { pane_id: pane, text: `:hold ${other}\n` }); + for (const deadline = Date.now() + 4000;;) { + if ((await read()).source === "scrollback") break; + if (Date.now() > deadline) throw new Error("ambiguous native holds did not disable chat"); + await Bun.sleep(50); + } + await report(other, 3); + // Herdr 0.9.3 ignores a replacement Copilot ID. Its stale old ID must not + // show that old session as if it belonged to the now-switched pane. + expect((await sessionSnapshot()).panes.find((p) => p.pane_id === pane)?.agent_session?.value).toBe(id); + expect((await read()).source).toBe("scrollback"); + expect((await fetch(`${url}&from=${encodeURIComponent(first.cursor!)}`)).status).toBe(409); + expect((await fetch(toolUrl)).status).toBe(404); + await herdrRpc("pane.send_text", { pane_id: pane, text: ":quit\n" }); + for (const deadline = Date.now() + 10_000;;) { + if ((await sessionSnapshot()).panes.find((p) => p.pane_id === pane)?.agent !== "copilot") break; + if (Date.now() > deadline) throw new Error("isolated fixture did not exit"); + await Bun.sleep(50); + } + await herdrRpc("pane.send_text", { pane_id: pane, text: `COPILOT_HOME=${home} node ${executable} ${other}\n` }); + for (const deadline = Date.now() + 10_000;;) { + if ((await sessionSnapshot()).panes.find((p) => p.pane_id === pane)?.agent === "copilot") break; + if (Date.now() > deadline) throw new Error("replacement fixture not detected"); + await Bun.sleep(50); + } + await report(other, 4); + expect((await read()).turns[0]?.parts[0]?.text).toBe("session B"); + expect((await fetch(`${url}&from=${encodeURIComponent(first.cursor!)}`)).status).toBe(409); + writeFileSync(file(other), fixture(id, "wrong header")); + expect((await read()).source).toBe("scrollback"); + rmSync(file(other)); + symlinkSync(file(id), file(other)); + expect((await read()).source).toBe("scrollback"); +}); diff --git a/server/copilot.test.ts b/server/copilot.test.ts new file mode 100644 index 0000000000..bb741731fb --- /dev/null +++ b/server/copilot.test.ts @@ -0,0 +1,239 @@ +import { expect, it } from "bun:test"; +import { appendFileSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseCopilotTranscript } from "./copilot.ts"; +import { copilotTranscriptFile, heldSessions } from "./copilot-store.ts"; +import { ConversationUnavailable, forgetTranscriptState, HistoryChanged, transcriptPage, transcriptToolOutput } from "./conversation.ts"; +import { parseConversationMetadata } from "./conversation-metadata.ts"; + +const session = "12aa83d8-48bc-41f2-a498-7055c5c44a01"; +const line = (type: string, data: object, agentId?: string) => + JSON.stringify({ type, data, id: crypto.randomUUID(), timestamp: "2026-10-21T00:00:00Z", parentId: null, ...(agentId ? { agentId } : {}) }) + "\n"; +const start = line("session.start", { sessionId: session, copilotVersion: "1.0.102-1" }); +const prompt = (text: string) => line("user.message", { content: text, transformedContent: "model-only text", turnId: crypto.randomUUID() }); +const answer = (text: string, phase = "final_answer") => line("assistant.message", { content: text, phase, model: "fixture-model", turnId: crypto.randomUUID() }); + +it("parses persisted Copilot messages and tool rounds once, with failures and recorded metadata", () => { + const text = start + prompt("Visible prompt") + + line("assistant.message", { content: "Checking.", phase: "commentary", toolRequests: [{ toolCallId: "call-1", name: "shell", arguments: { command: "pwd" }, toolTitle: "Check directory" }], model: "fixture-model" }) + + line("tool.execution_start", { toolCallId: "call-1", toolName: "shell" }) + + line("tool.execution_complete", { toolCallId: "call-1", success: false, result: { content: "failure ".repeat(900) } }) + + answer("Finished.") + line("system.message", { content: "not a prompt" }) + + line("session.compaction_complete", { success: true, summaryContent: "internal summary" }) + + line("assistant.message", { content: "another agent", model: "leaked-model" }, "child-agent") + + line("user.message", { content: "child prompt" }, "child-agent") + + line("user.message", { content: "nested prompt", parentAgentTaskId: "task-1", agentId: "child-agent" }) + + line("assistant.message", { content: "nested answer", agentId: "child-agent", model: "nested-model" }) + + '{"type":"user.message","data":'; + const turns = parseCopilotTranscript(text); + expect(turns.map(({ role }) => role)).toEqual(["user", "assistant"]); + expect(turns[0]?.parts).toEqual([{ kind: "text", text: "Visible prompt" }]); + expect(turns[1]?.parts[0]).toEqual({ kind: "text", text: "Checking.", phase: "commentary" }); + expect(turns[1]?.parts[1]).toMatchObject({ kind: "tool", name: "shell", summary: "Check directory", error: true, output_ref: "call-1" }); + expect(turns[1]?.parts[2]).toEqual({ kind: "text", text: "Finished.", phase: "final_answer" }); + expect(parseConversationMetadata(text, "copilot-transcript")).toEqual({ model: "fixture-model", reasoning_effort: null }); +}); + +it("keeps root prompts carrying parentAgentTaskId and pages at those prompts", () => { + const dir = mkdtempSync(join(tmpdir(), "copilot-root-page-")); + const path = join(dir, "events.jsonl"); + try { + const root = (i: number) => line("user.message", { content: `root ${i}`, parentAgentTaskId: `task-${i}` }); + writeFileSync(path, start + Array.from({ length: 65 }, (_, i) => root(i) + answer(`response ${i}`) + + line("user.message", { content: `child ${i}`, parentAgentTaskId: `task-${i}`, agentId: "child" }, "child")).join("")); + const latest = transcriptPage("copilot-transcript", path); + expect(latest.turns.filter((turn) => turn.role === "user").map((turn) => turn.parts[0])).toContainEqual({ kind: "text", text: "root 64" }); + expect(latest.cursor).not.toBeNull(); + const older = transcriptPage("copilot-transcript", path, { before: latest.cursor! }); + expect(older.turns[0]?.parts[0]).toEqual({ kind: "text", text: "root 0" }); + expect([...latest.turns, ...older.turns].some((turn) => turn.parts.some((part) => part.kind === "text" && part.text.startsWith("child")))).toBe(false); + } finally { forgetTranscriptState(); rmSync(dir, { recursive: true, force: true }); } +}); + +it("reads initial model and effort then folds native changes in order", () => { + const header = line("session.start", { sessionId: session, selectedModel: "initial-model", reasoningEffort: "high" }); + expect(parseConversationMetadata(header, "copilot-transcript")).toEqual({ model: "initial-model", reasoning_effort: "high" }); + expect(parseConversationMetadata(header + line("session.model_change", { newModel: "next-model", reasoningEffort: "low" }) + + line("session.resume", { selectedModel: "resumed-model", reasoningEffort: "medium" }), "copilot-transcript")) + .toEqual({ model: "resumed-model", reasoning_effort: "medium" }); +}); + +it("keeps unphased completed prose available as an answer, and explicit commentary in work", () => { + const turns = parseCopilotTranscript(start + prompt("question") + line("assistant.message", { content: "work", phase: "commentary" }) + + line("assistant.message", { content: "unphased answer" }) + line("assistant.turn_end", {})); + expect(turns.at(-1)?.parts).toEqual([{ kind: "text", text: "work", phase: "commentary" }, { kind: "text", text: "unphased answer" }]); +}); + +it("pages bounded native turns and observes append, torn lines, and same-file resume without reset", () => { + const dir = mkdtempSync(join(tmpdir(), "copilot-page-")); + const path = join(dir, "events.jsonl"); + try { + writeFileSync(path, start + Array.from({ length: 65 }, (_, i) => prompt(`prompt ${i}`) + answer(`answer ${i}`)).join("")); + const first = transcriptPage("copilot-transcript", path); + expect(first.turns.at(-1)?.parts[0]).toEqual({ kind: "text", text: "answer 64", phase: "final_answer" }); + expect(first.cursor).not.toBeNull(); + const older = transcriptPage("copilot-transcript", path, { before: first.cursor! }); + expect(older.turns[0]?.parts[0]).toEqual({ kind: "text", text: "prompt 0" }); + appendFileSync(path, '{"type":"user.message","data":'); + expect(transcriptPage("copilot-transcript", path).turns).toEqual(first.turns); + appendFileSync(path, '\n' + line("session.resume", { selectedModel: "fixture-model" }) + prompt("after resume") + answer("still here")); + const resumed = transcriptPage("copilot-transcript", path); + expect(resumed.history_id).toBe(first.history_id); + expect(resumed.turns.at(-1)?.parts[0]).toEqual({ kind: "text", text: "still here", phase: "final_answer" }); + expect(transcriptPage("copilot-transcript", path, { before: first.cursor! }).turns).toEqual(older.turns); + writeFileSync(path, start + prompt("replacement")); + expect(() => transcriptPage("copilot-transcript", path, { before: first.cursor! })).toThrow(HistoryChanged); + } finally { forgetTranscriptState(); rmSync(dir, { recursive: true, force: true }); } +}); + +it("fetches only a bounded complete output for the selected native call ID", () => { + const dir = mkdtempSync(join(tmpdir(), "copilot-output-")); + const path = join(dir, "events.jsonl"); + try { + writeFileSync(path, start + prompt("tool") + + line("assistant.message", { content: "", toolRequests: [{ toolCallId: "call-1", name: "shell", arguments: {} }] }) + + line("tool.execution_complete", { toolCallId: "call-1", success: true, result: { content: "x".repeat(5000) } }) + + line("tool.execution_complete", { toolCallId: "unrelated", success: true, result: { content: "hidden" } })); + expect(transcriptPage("copilot-transcript", path).turns.at(-1)?.parts[0]).toMatchObject({ kind: "tool", output_ref: "call-1" }); + expect(transcriptToolOutput("copilot-transcript", path, "call-1")).toBe("x".repeat(5000)); + expect(transcriptToolOutput("copilot-transcript", path, "../secret")).toBeNull(); + expect(transcriptToolOutput("copilot-transcript", path, "unrelated")).toBeNull(); + const alias = join(dir, "alias.jsonl"); + symlinkSync(path, alias); + expect(() => transcriptPage("copilot-transcript", alias)).toThrow(ConversationUnavailable); + expect(transcriptToolOutput("copilot-transcript", alias, "call-1")).toBeNull(); + } finally { forgetTranscriptState(); rmSync(dir, { recursive: true, force: true }); } +}); + +it("reads a complete Copilot tool result at EOF across read chunks, but refuses malformed or oversized EOF records", () => { + const dir = mkdtempSync(join(tmpdir(), "copilot-output-eof-")); + const path = join(dir, "events.jsonl"); + try { + const request = start + line("assistant.message", { toolRequests: [{ toolCallId: "eof-call", name: "shell" }] }); + const complete = line("tool.execution_complete", { toolCallId: "eof-call", success: true, result: { content: "z".repeat(70_000) } }).trimEnd(); + writeFileSync(path, request + complete); + expect(transcriptPage("copilot-transcript", path).turns.at(-1)?.parts[0]).toMatchObject({ kind: "tool", output_ref: "eof-call" }); + expect(transcriptToolOutput("copilot-transcript", path, "eof-call")).toBe("z".repeat(70_000)); + writeFileSync(path, request + complete.slice(0, -1)); + expect(transcriptToolOutput("copilot-transcript", path, "eof-call")).toBeNull(); + writeFileSync(path, request + line("tool.execution_complete", { toolCallId: "eof-call", result: { content: "z".repeat(9_000_000) } }).trimEnd()); + expect(transcriptToolOutput("copilot-transcript", path, "eof-call")).toBeNull(); + } finally { forgetTranscriptState(); rmSync(dir, { recursive: true, force: true }); } +}); + +it("retains root Copilot model and effort controls across the head and cold latest-page gap in order", () => { + const dir = mkdtempSync(join(tmpdir(), "copilot-cold-metadata-")); + const path = join(dir, "events.jsonl"); + const fillGap = () => { + const turn = prompt("filler") + line("assistant.message", { content: "f".repeat(32_000), phase: "final_answer" }); + // Each control is now more than a 16 MiB page away from the next cold read. + appendFileSync(path, turn.repeat(540)); + }; + try { + writeFileSync(path, line("session.start", { sessionId: session, selectedModel: "initial", reasoningEffort: "high" }) + + line("system.message", { content: "padding".repeat(10_000) }) + + line("session.model_change", { newModel: "child-model", reasoningEffort: "high", agentId: "child" }, "child") + + line("session.model_change", { newModel: "low-model", reasoningEffort: "low" }) + + line("session.model_change", { newModel: "latest-model" })); + fillGap(); + forgetTranscriptState(); + expect(transcriptPage("copilot-transcript", path).metadata).toEqual({ model: "latest-model", reasoning_effort: "low" }); + appendFileSync(path, line("session.resume", { selectedModel: "child-model", reasoningEffort: "high", agentId: "child" }, "child") + + line("session.resume", { selectedModel: "resumed-model", reasoningEffort: "medium" }) + + line("session.resume", { selectedModel: "latest-resumed-model" })); + fillGap(); + forgetTranscriptState(); + expect(transcriptPage("copilot-transcript", path).metadata).toEqual({ model: "latest-resumed-model", reasoning_effort: "medium" }); + } finally { forgetTranscriptState(); rmSync(dir, { recursive: true, force: true }); } +}); + +it("skips oversized unrelated records and bounds selected output before parsing", () => { + const dir = mkdtempSync(join(tmpdir(), "copilot-large-output-")); + const path = join(dir, "events.jsonl"); + try { + const request = line("assistant.message", { toolRequests: [{ toolCallId: "chosen", name: "shell" }] }); + writeFileSync(path, start + request + + line("tool.execution_complete", { toolCallId: "other", result: { content: "u".repeat(10_000_000) } }) + + line("tool.execution_complete", { toolCallId: "chosen", result: { content: "v".repeat(2_100_000) } })); + expect(transcriptToolOutput("copilot-transcript", path, "chosen")).toBe(`${"v".repeat(2_000_000)}\n… trimmed`); + expect(transcriptToolOutput("copilot-transcript", path, "../invalid")).toBeNull(); + writeFileSync(path, start + request + line("tool.execution_complete", { toolCallId: "chosen", result: { content: "w".repeat(10_000_000) } }) + + line("tool.execution_complete", { toolCallId: "chosen", result: { content: "incorrect fallback" } })); + expect(transcriptToolOutput("copilot-transcript", path, "chosen")).toBeNull(); + } finally { forgetTranscriptState(); rmSync(dir, { recursive: true, force: true }); } +}); + +it.skipIf(process.platform !== "linux")("uses only the process home and a canonical regular file with the matching native header", async () => { + const root = mkdtempSync(join(tmpdir(), "copilot-store-")); + const custom = join(root, "custom"); + const defaultHome = join(root, "user"); + const legacy = join(root, "legacy"); + const fixture = (home: string) => { + const directory = join(home, "session-state", session); + mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, "events.jsonl"), start + prompt("safe")); + return join(directory, "events.jsonl"); + }; + let stop: (() => Promise) | undefined; + try { + const chosen = fixture(custom); + const defaultFile = fixture(join(defaultHome, ".copilot")); + const legacyFile = fixture(legacy); + const child = Bun.spawn(["node", "-e", ` + const { openSync, closeSync } = require("node:fs"); + const { join } = require("node:path"); + const homes = process.argv.slice(1); + for (const home of homes) openSync(join(home, "session-state", "${session}", "inuse." + process.pid + ".hold"), "w"); + let extra; + let input = ""; + process.stdin.on("data", (chunk) => { + input += chunk; + for (let at; (at = input.indexOf("\\n")) !== -1;) { + const command = input.slice(0, at); input = input.slice(at + 1); + if (command === "extra") extra = openSync(join(homes[0], "session-state", "${session.slice(0, -2)}02", "inuse." + process.pid + ".hold"), "w"); + if (command === "close") closeSync(extra); + console.log("ok"); + } + }); + console.log("ready"); + `, custom, join(defaultHome, ".copilot"), legacy], { stdin: "pipe", stdout: "pipe", stderr: "inherit" }); + stop = () => { child.kill(); return child.exited; }; + const reader = child.stdout.getReader(); + expect(new TextDecoder().decode((await reader.read()).value)).toContain("ready"); + const pid = child.pid; + const command = async (value: string) => { + child.stdin.write(`${value}\n`); + expect(new TextDecoder().decode((await reader.read()).value)).toContain("ok"); + }; + expect([...heldSessions(pid, join(custom, "session-state"))!]).toEqual([session]); + expect(copilotTranscriptFile(session, [`HOME=${defaultHome}`, `COPILOT_HOME=${custom}`], ["copilot"], pid)).toBe(chosen); + expect(copilotTranscriptFile(session, null, ["copilot"], pid)).toBeNull(); + expect(copilotTranscriptFile("../bad", [`COPILOT_HOME=${custom}`], ["copilot"], pid)).toBeNull(); + expect(copilotTranscriptFile(session, [`COPILOT_HOME=relative`], ["copilot"], pid)).toBeNull(); + writeFileSync(chosen, start.replace(session, "12aa83d8-48bc-41f2-a498-7055c5c44a02")); + expect(copilotTranscriptFile(session, [`COPILOT_HOME=${custom}`], ["copilot"], pid)).toBeNull(); + rmSync(chosen); + symlinkSync(legacyFile, chosen); + expect(copilotTranscriptFile(session, [`COPILOT_HOME=${custom}`], ["copilot"], pid)).toBeNull(); + rmSync(chosen); + mkdirSync(chosen); + expect(copilotTranscriptFile(session, [`COPILOT_HOME=${custom}`], ["copilot"], pid)).toBeNull(); + rmSync(chosen, { recursive: true }); + expect(await Bun.spawn(["mkfifo", chosen], { stdout: "ignore", stderr: "inherit" }).exited).toBe(0); + expect(copilotTranscriptFile(session, [`COPILOT_HOME=${custom}`], ["copilot"], pid)).toBeNull(); + rmSync(chosen); + writeFileSync(chosen, start); + const extra = "12aa83d8-48bc-41f2-a498-7055c5c44a02"; + mkdirSync(join(custom, "session-state", extra)); + await command("extra"); + expect(copilotTranscriptFile(session, [`COPILOT_HOME=${custom}`], ["copilot"], pid)).toBeNull(); + await command("close"); + expect(copilotTranscriptFile(session, [`COPILOT_HOME=${custom}`], ["copilot"], pid)).toBe(chosen); + expect(copilotTranscriptFile(session, [`HOME=${defaultHome}`], ["copilot"], pid)).toBe(defaultFile); + expect(copilotTranscriptFile(session, [`HOME=${defaultHome}`, `COPILOT_HOME=${custom}`], ["copilot", "--config-dir", legacy], pid)).toBe(legacyFile); + } finally { + if (stop) await stop(); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/server/copilot.ts b/server/copilot.ts new file mode 100644 index 0000000000..72a64fe88e --- /dev/null +++ b/server/copilot.ts @@ -0,0 +1,159 @@ +import type { ConversationPart, ConversationTurn } from "../shared/protocol.ts"; +import { readSync } from "node:fs"; +import { MAX_TURNS, toolSummary } from "./transcript-records.ts"; +import { trimOutput } from "./tool-output.ts"; + +type Row = Record; +const row = (value: unknown): Row | null => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Row : null; +const text = (value: unknown): string | null => typeof value === "string" && value.trim() ? value : null; +const CALL_ID = /^[A-Za-z0-9_:.-]{1,128}$/; + +/** A task parent can occur on a root prompt; only agent identity marks a child. */ +export function copilotRootRecord(value: unknown): boolean { + const entry = row(value); + return entry !== null && entry.agentId == null && row(entry.data)?.agentId == null; +} + +/** Lines written by the CLI, not its ephemeral streaming updates or nested agents. */ +export function copilotRecords(jsonl: string): { type: string; data: Row; timestamp: string | null }[] { + const records: { type: string; data: Row; timestamp: string | null }[] = []; + for (const line of jsonl.split("\n")) { + let value: unknown; + try { value = JSON.parse(line); } catch { continue; } + const entry = row(value); + if (!entry || typeof entry.type !== "string" || !copilotRootRecord(entry)) continue; + const data = row(entry.data); + if (data === null) continue; + records.push({ type: entry.type, data, timestamp: typeof entry.timestamp === "string" ? entry.timestamp : null }); + } + return records; +} + +/** Convert persisted Copilot CLI turns to the shared, read-only chat shape. */ +export function parseCopilotTranscript(jsonl: string, maxTurns = MAX_TURNS): ConversationTurn[] { + const turns: ConversationTurn[] = []; + const pending = new Map>(); + let finished = false; + const assistant = (ts: string | null): ConversationTurn => { + const last = turns.at(-1); + if (last?.role === "assistant" && !finished) return last; + const turn: ConversationTurn = { role: "assistant", ts, parts: [] }; + turns.push(turn); + finished = false; + return turn; + }; + for (const { type, data, timestamp } of copilotRecords(jsonl)) { + if (type === "user.message") { + const content = text(data.content); + if (content !== null) { + turns.push({ role: "user", ts: timestamp, parts: [{ kind: "text", text: content }] }); + pending.clear(); + finished = false; + } + } else if (type === "assistant.message") { + const content = text(data.content); + const requests = Array.isArray(data.toolRequests) ? data.toolRequests : []; + if (content === null && requests.length === 0) continue; + const turn = assistant(timestamp); + if (content !== null) turn.parts.push({ + kind: "text", text: content, + ...(data.phase === "final_answer" || data.phase === "commentary" ? { phase: data.phase } : {}), + }); + for (const raw of requests) { + const request = row(raw); + if (!request || typeof request.toolCallId !== "string" || !CALL_ID.test(request.toolCallId) + || pending.has(request.toolCallId)) continue; + const name = text(request.name) ?? "tool"; + const args = row(request.arguments) ?? {}; + const part: Extract = { + kind: "tool", name, summary: text(request.toolTitle) ?? text(request.intentionSummary) ?? toolSummary(name, args), + input: JSON.stringify(args, null, 2).slice(0, 16_000), output: "", + }; + turn.parts.push(part); + pending.set(request.toolCallId, part); + } + if (data.phase === "final_answer") finished = true; + } else if (type === "tool.execution_start") { + const id = data.toolCallId; + if (typeof id !== "string" || !CALL_ID.test(id) || pending.has(id)) continue; + const name = text(data.toolName) ?? "tool"; + const args = row(data.arguments) ?? {}; + const part: Extract = { + kind: "tool", name, summary: text(data.toolTitle) ?? toolSummary(name, args), + input: JSON.stringify(args, null, 2).slice(0, 16_000), output: "", + }; + assistant(timestamp).parts.push(part); + pending.set(id, part); + } else if (type === "tool.execution_complete") { + const id = data.toolCallId; + const part = typeof id === "string" ? pending.get(id) : undefined; + if (!part || typeof id !== "string") continue; + const output = text(row(data.result)?.content) ?? ""; + trimOutput(part, output, id); + if (data.success === false) part.error = true; + pending.delete(id); + } else if (type === "assistant.turn_end") { + const last = turns.at(-1); + if (last?.role === "assistant" && !finished && timestamp !== null) last.end_ts = timestamp; + } + } + return turns.filter((turn) => turn.parts.length > 0).slice(-maxTurns); +} + +/** + * Scan a pinned descriptor forward in 64 KiB reads, holding at most one 8 MiB record. + * Oversized unrelated lines are discarded without parsing; an oversized line that + * mentions the requested ID fails closed. I/O is linear up to the first result (or + * EOF for a missing ID), never a whole-file read/index on the server event loop. + */ +export function copilotOutput(fd: number, ref: string, outputMax: number): string | null { + if (!CALL_ID.test(ref)) return null; + const chunk = Buffer.alloc(64 * 1024); + const needle = Buffer.from(JSON.stringify(ref)); + const recordMax = 8 * 1024 * 1024; + let pieces: Buffer[] = []; + let length = 0; + let oversized = false; + let mentionsRef = false; + let overlap = Buffer.alloc(0); + let requested = false; + const consume = (): string | null | undefined => { + if (oversized) return mentionsRef ? null : undefined; + if (!mentionsRef) return undefined; + let entry: Row | null; + try { entry = row(JSON.parse(Buffer.concat(pieces, length).toString("utf8"))); } + catch { return undefined; } + if (!copilotRootRecord(entry)) return undefined; + const data = row(entry?.data); + if (data === null) return undefined; + if (entry?.type === "assistant.message" && Array.isArray(data.toolRequests)) { + requested ||= data.toolRequests.some((value) => row(value)?.toolCallId === ref); + } + if (entry?.type === "tool.execution_start" && data.toolCallId === ref) requested = true; + if (entry?.type !== "tool.execution_complete" || data.toolCallId !== ref) return undefined; + if (!requested) return null; + const content = row(data.result)?.content; + return typeof content === "string" ? content.length > outputMax ? `${content.slice(0, outputMax)}\n… trimmed` : content : null; + }; + for (let count; (count = readSync(fd, chunk, 0, chunk.length, null)) > 0;) { + for (let at = 0; at < count;) { + const newline = chunk.indexOf(0x0a, at); + const end = newline < 0 || newline >= count ? count : newline; + const segment = chunk.subarray(at, end); + // Keep the needle's trailing bytes so a call ID split across reads is still detected. + const search = overlap.length ? Buffer.concat([overlap, segment]) : segment; + if (search.includes(needle)) mentionsRef = true; + overlap = Buffer.from(search.subarray(Math.max(0, search.length - needle.length + 1))); + if (!oversized && length + segment.length <= recordMax) { pieces.push(Buffer.from(segment)); length += segment.length; } + else { oversized = true; pieces = []; length = 0; } + if (newline < 0 || newline >= count) break; + const result = consume(); + if (result !== undefined) return result; + pieces = []; length = 0; oversized = false; mentionsRef = false; overlap = Buffer.alloc(0); + at = newline + 1; + } + } + // JSONL may end in a complete object without a newline. Use the same bounded + // record path as newline-terminated records; malformed tails still return null. + return consume() ?? null; +} diff --git a/shared/protocol.ts b/shared/protocol.ts index acc3a3fdd0..2edcd878f1 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -664,7 +664,7 @@ export interface ConversationResponse { activity?: "thinking" | "working" | "idle"; /** Stable across appends; changes on transcript replacement or native context clear. */ history_id?: string; - source: "grok-transcript" | "agy-transcript" | "claude-transcript" | "omp-transcript" | "omo-transcript" | "gjc-transcript" | "pi-transcript" | "cmd-transcript" | "codex-transcript" | "devin-transcript" | "opencode-transcript" | "hermes-transcript" | "scrollback"; + source: "copilot-transcript" | "grok-transcript" | "agy-transcript" | "claude-transcript" | "omp-transcript" | "omo-transcript" | "gjc-transcript" | "pi-transcript" | "cmd-transcript" | "codex-transcript" | "devin-transcript" | "opencode-transcript" | "hermes-transcript" | "scrollback"; turns: ConversationTurn[]; metadata?: ConversationMetadata; /** From 398b53162721cac24e5d0164afc225466c61b4a6 Mon Sep 17 00:00:00 2001 From: Yu Xie Date: Thu, 22 Oct 2026 13:00:15 +0200 Subject: [PATCH 02/25] fix(chat): recognize new Copilot sessions before the first message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1fad73aa-cbc5-42dd-916b-64e5198bc512 --- changelog.d/copilot-chat-startup.md | 3 +++ server/copilot.contract.test.ts | 9 +++++++++ server/launched-agents.test.ts | 11 +++++++++++ server/launched-agents.ts | 1 + 4 files changed, 24 insertions(+) create mode 100644 changelog.d/copilot-chat-startup.md diff --git a/changelog.d/copilot-chat-startup.md b/changelog.d/copilot-chat-startup.md new file mode 100644 index 0000000000..f0bf613231 --- /dev/null +++ b/changelog.d/copilot-chat-startup.md @@ -0,0 +1,3 @@ +### Fixed +- New Copilot sessions launched from the web UI show an empty chat before the first message, + instead of reporting that the conversation is unavailable. diff --git a/server/copilot.contract.test.ts b/server/copilot.contract.test.ts index fe6df9fbc5..b33f1e933d 100644 --- a/server/copilot.contract.test.ts +++ b/server/copilot.contract.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { createServer } from "./index.ts"; import { herdrRpc, sessionSnapshot, workspaceClose, workspaceCreate } from "./herdr/client.ts"; import { forgetTranscriptState } from "./conversation.ts"; +import { launchedAgents } from "./launched-agents.ts"; const id = "12aa83d8-48bc-41f2-a498-7055c5c44a01"; const other = "12aa83d8-48bc-41f2-a498-7055c5c44a02"; @@ -56,6 +57,7 @@ console.log("fixture ready");`); afterAll(async () => { app?.stop(); + if (pane) launchedAgents.forget(pane); if (workspace) await workspaceClose(workspace); forgetTranscriptState(); rmSync(root, { recursive: true, force: true }); @@ -70,9 +72,16 @@ it.skipIf(process.platform !== "linux")("binds only the reported UUID to the for }; await report(id, 1, "untrusted"); expect((await read()).source).toBe("scrollback"); + launchedAgents.launched(pane, "copilot"); + const pending = await read(); + expect(pending.source).toBe("copilot-transcript"); + expect(pending.turns).toEqual([]); + expect(pending.history_id).toStartWith("unwritten:launch:"); + expect((await read()).history_id).toBe(pending.history_id); await report(id, 2); const first = await read(); expect(first.source).toBe("copilot-transcript"); + expect(first.history_id).not.toBe(pending.history_id); expect(first.turns[0]?.parts[0]?.text).toBe("session A"); appendFileSync(file(id), event("assistant.message", { content: "Checking.", phase: "commentary", toolRequests: [{ toolCallId: "call-1", name: "shell", arguments: { command: "pwd" } }], model: "fixture-model" })); diff --git a/server/launched-agents.test.ts b/server/launched-agents.test.ts index 1914b87c3f..c6f25ef885 100644 --- a/server/launched-agents.test.ts +++ b/server/launched-agents.test.ts @@ -29,6 +29,17 @@ describe("LaunchedAgents", () => { expect(launched.notBegun("w1:p1", "done", 60_000)).toBeNull(); }); + it("keeps a new Copilot chat empty until its first turn, without claiming an untracked pane", () => { + const launched = new LaunchedAgents(); + launched.launched("w1:p1", "copilot", 0); + const first = launched.notBegun("w1:p1", "idle", 5_000); + expect(first).toEqual({ source: "copilot-transcript", historyId: "unwritten:launch:w1:p1:1" }); + expect(launched.notBegun("w1:p1", "idle", 6_000)).toEqual(first); + expect(launched.notBegun("w1:p2", "idle", 6_000)).toBeNull(); + expect(launched.notBegun("w1:p1", "working", 7_000)).toBeNull(); + expect(launched.notBegun("w1:p1", "done", 8_000)).toBeNull(); + }); + it("takes an agent at work long after its launch for one on its first turn", () => { const launched = new LaunchedAgents(); launched.launched("w1:p1", "opencode", 0); diff --git a/server/launched-agents.ts b/server/launched-agents.ts index 21792b77ef..0683780fa8 100644 --- a/server/launched-agents.ts +++ b/server/launched-agents.ts @@ -14,6 +14,7 @@ import type { RecognizedConversation } from "./conversation.ts"; */ const SOURCES: Record = { agy: "agy-transcript", claude: "claude-transcript", codex: "codex-transcript", devin: "devin-transcript", + copilot: "copilot-transcript", gjc: "gjc-transcript", grok: "grok-transcript", hermes: "hermes-transcript", omo: "omo-transcript", omp: "omp-transcript", opencode: "opencode-transcript", pi: "pi-transcript", }; From fcbc616146d33b6314e824ae9977d237e688241e Mon Sep 17 00:00:00 2001 From: Yu Xie Date: Thu, 22 Oct 2026 13:31:34 +0200 Subject: [PATCH 03/25] fix(chat): recognize unwritten Copilot sessions started in terminals Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1fad73aa-cbc5-42dd-916b-64e5198bc512 --- changelog.d/copilot-chat-startup.md | 4 +-- docs/guide.md | 6 +++++ server/conversation.ts | 27 +++++++++++++------- server/copilot-store.ts | 39 ++++++++++++++++++++++------- server/copilot.contract.test.ts | 10 +++++++- server/copilot.test.ts | 21 +++++++++++++++- 6 files changed, 85 insertions(+), 22 deletions(-) diff --git a/changelog.d/copilot-chat-startup.md b/changelog.d/copilot-chat-startup.md index f0bf613231..a40c31ffd8 100644 --- a/changelog.d/copilot-chat-startup.md +++ b/changelog.d/copilot-chat-startup.md @@ -1,3 +1,3 @@ ### Fixed -- New Copilot sessions launched from the web UI show an empty chat before the first message, - instead of reporting that the conversation is unavailable. +- New Copilot sessions show an empty chat before the first message, including sessions started + in the terminal, instead of reporting that the conversation is unavailable. diff --git a/docs/guide.md b/docs/guide.md index f8ead969d0..de41920b06 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -168,6 +168,12 @@ Every agent herdr runs shows up with its live status, terminal and alerts. The c | **Antigravity (agy)** | **Experimental; macOS untested.** Native ledger, selected by the running process’s held conversation lock. Linux native-tested; Windows uses terminal text. See [Agy behavior and limits](agy-chat.md). | — use Terminal | | **Anything else** | The terminal's text | — use Terminal | +Before its first message, a new Linux Copilot process can show an empty chat even when started +from Terminal. The bridge verifies that the pane's process holds exactly one session in its own +Copilot home and that its transcript does not exist yet. Existing history still requires the +SessionStart hook; unreadable files, explicit resume/continue launches and ambiguous identities +keep the terminal fallback rather than appearing as an empty conversation. + GJC chat requires GJC 0.18.9 or later for its broker-bound SDK session CLI, with `gjc` on the server PC's PATH and its SDK broker already running. An older GJC opens in Terminal. Git must also be on PATH for the SDK's workspace-scope discovery. diff --git a/server/conversation.ts b/server/conversation.ts index b48bda123a..adf0b2bc23 100644 --- a/server/conversation.ts +++ b/server/conversation.ts @@ -59,7 +59,7 @@ import { withinRetentionBudget } from "./retention-budget.ts"; import { forgetAllPiIndexes, forgetPiIndex, piAbandonedTurns, piBranchRecords, piBranchSegments } from "./pi-tree.ts"; import { trimOutput } from "./tool-output.ts"; import { copilotOutput, copilotRootRecord, parseCopilotTranscript } from "./copilot.ts"; -import { copilotTranscriptFile, openCopilotFile } from "./copilot-store.ts"; +import { copilotTranscriptFile, copilotUnwrittenSession, openCopilotFile } from "./copilot-store.ts"; import { parseConversationMetadata } from "./conversation-metadata.ts"; import { forgetHermesTranscriptState, hermesConversationPage, hermesToolOutput, hermesTranscriptForPane } from "./hermes.ts"; @@ -1569,13 +1569,14 @@ async function ompTranscriptPath(paneId: string, cwd: string): Promise { return inStore; } -/** Herdr's SessionStart hook owns the identity; argv and directory recency never do. */ +/** History requires Herdr's hook; a process-held unwritten session can show an empty chat. */ async function copilotTranscriptPath(pane: HerdrPane, panes: readonly HerdrPane[]): Promise { const report = pane.agent_session; - if (pane.agent !== "copilot" || report?.agent !== "copilot" || report.kind !== "id" + const claimedByPeer = (session: string) => panes.some((peer) => peer.pane_id !== pane.pane_id + && peer.agent_session?.agent === "copilot" && peer.agent_session.kind === "id" && peer.agent_session.value === session); + if (pane.agent !== "copilot" || (report != null && (report.agent !== "copilot" || report.kind !== "id" || report.source !== "herdr:copilot" || typeof report.value !== "string" || !SESSION_ID.test(report.value) - || panes.some((peer) => peer.pane_id !== pane.pane_id && peer.agent_session?.agent === "copilot" - && peer.agent_session.kind === "id" && peer.agent_session.value === report.value)) { + || claimedByPeer(report.value)))) { throw new ConversationUnavailable("no_session_id"); } const info = await paneProcessInfo(pane.pane_id).catch(() => null); @@ -1584,9 +1585,17 @@ async function copilotTranscriptPath(pane: HerdrPane, panes: readonly HerdrPane[ if (process.platform !== "linux" || processes.length !== 1 || typeof foreground?.pid !== "number") { throw new ConversationUnavailable("no_session_path"); } - const path = copilotTranscriptFile(report.value, processEnviron(foreground.pid), foreground.argv ?? [], foreground.pid); - if (path === null) throw new ConversationUnavailable("no_session_path"); - return path; + const environment = processEnviron(foreground.pid); + const argv = foreground.argv ?? []; + if (report != null) { + const path = copilotTranscriptFile(report.value, environment, argv, foreground.pid); + if (path !== null) return path; + } + const pending = copilotUnwrittenSession(environment, argv, foreground.pid); + if (pending !== null && (report == null || report.value === pending.sessionId) && !claimedByPeer(pending.sessionId)) { + throw new ConversationNotStarted(pending.sessionId, "copilot-transcript", pending.path); + } + throw new ConversationUnavailable(report == null ? "no_session_id" : "no_session_path"); } /** Where a pane's conversation is: a transcript file, or a session in OpenCode's database. */ @@ -1872,7 +1881,7 @@ export async function paneConversation(paneId: string, codexHome?: string, page: throw error; } } - const identity = resolved.source === "agy-transcript" ? resolved.path + const identity = resolved.source === "agy-transcript" || resolved.source === "copilot-transcript" ? resolved.path : 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); diff --git a/server/copilot-store.ts b/server/copilot-store.ts index 9361c8861b..a86e8e8ed6 100644 --- a/server/copilot-store.ts +++ b/server/copilot-store.ts @@ -1,4 +1,4 @@ -import { constants, closeSync, fstatSync, openSync, opendirSync, readlinkSync, readSync, realpathSync, statSync } from "node:fs"; +import { constants, closeSync, fstatSync, lstatSync, openSync, opendirSync, readlinkSync, readSync, realpathSync, statSync } from "node:fs"; import { basename, dirname, isAbsolute, join, relative, sep } from "node:path"; const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -72,9 +72,8 @@ export function heldSessions(pid: number, store: string): Set | null { return null; } -/** Only the reported session, beneath the foreground process's own Copilot home. */ -export function copilotTranscriptFile(session: unknown, environment: readonly string[] | null, argv: readonly string[], pid: number): string | null { - if (typeof session !== "string" || !SESSION_ID.test(session) || environment === null || !Number.isSafeInteger(pid) || pid < 1) return null; +function processSessionFile(environment: readonly string[] | null, argv: readonly string[], pid: number): { sessionId: string; path: string } | null { + if (environment === null || !Number.isSafeInteger(pid) || pid < 1) return null; const values = (key: string) => environment.filter((item) => item.startsWith(`${key}=`)).map((item) => item.slice(key.length + 1)); const home = values("HOME"); const explicit = values("COPILOT_HOME"); @@ -95,14 +94,36 @@ export function copilotTranscriptFile(session: unknown, environment: readonly st const store = join(canonicalHome, "session-state"); if (realpathSync(store) !== store) return null; const held = heldSessions(pid, store); - if (held === null || held.size !== 1 || !held.has(session)) return null; + if (held === null || held.size !== 1) return null; + const session = [...held][0]!; const directory = join(store, session); if (realpathSync(directory) !== directory) return null; - const file = join(directory, "events.jsonl"); - if (realpathSync(file) !== file) return null; - const fd = openCopilotFile(file); + return { sessionId: session, path: join(directory, "events.jsonl") }; + } catch { return null; } +} + +/** Only the reported session, beneath the foreground process's own Copilot home. */ +export function copilotTranscriptFile(session: unknown, environment: readonly string[] | null, argv: readonly string[], pid: number): string | null { + if (typeof session !== "string" || !SESSION_ID.test(session)) return null; + const held = processSessionFile(environment, argv, pid); + if (held === null || held.sessionId !== session) return null; + try { + const fd = openCopilotFile(held.path); try { - return file; + return held.path; } finally { closeSync(fd); } } catch { return null; } } + +/** A process-held session with no transcript yet, never permission to read unreported history. */ +export function copilotUnwrittenSession(environment: readonly string[] | null, argv: readonly string[], pid: number): { sessionId: string; path: string } | null { + if (argv.some((word) => word === "--continue" || word === "-r" || word === "--resume" || word.startsWith("--resume="))) return null; + const held = processSessionFile(environment, argv, pid); + if (held === null) return null; + try { + lstatSync(held.path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return held; + } + return null; +} diff --git a/server/copilot.contract.test.ts b/server/copilot.contract.test.ts index b33f1e933d..f14b8e711d 100644 --- a/server/copilot.contract.test.ts +++ b/server/copilot.contract.test.ts @@ -24,7 +24,6 @@ let pane: string; beforeAll(async () => { for (const session of [id, other]) mkdirSync(join(home, "session-state", session), { recursive: true }); - writeFileSync(file(id), fixture(id, "session A")); writeFileSync(executable, `import { openSync, mkdirSync, writeFileSync, appendFileSync } from "node:fs"; import { join } from "node:path"; const home = process.env.COPILOT_HOME; @@ -66,6 +65,13 @@ afterAll(async () => { it.skipIf(process.platform !== "linux")("binds only the reported UUID to the foreground process's home and rejects old cursors after a switch", async () => { const url = `http://127.0.0.1:${app.port}/api/pane/conversation?pane_id=${encodeURIComponent(pane)}`; const read = async () => (await fetch(url)).json() as Promise<{ source: string; turns: { parts: { text?: string }[] }[]; cursor?: string; history_id?: string }>; + const external = await read(); + expect(external.source).toBe("copilot-transcript"); + expect(external.turns).toEqual([]); + expect(external.history_id).toBe(`unwritten:${id}`); + expect((await read()).history_id).toBe(external.history_id); + expect((await fetch(`${url}&from=old-history`)).status).toBe(409); + writeFileSync(file(id), fixture(id, "session A")); expect((await read()).source).toBe("scrollback"); const report = async (session: string, seq: number, source = "herdr:copilot", paneId = pane) => { await herdrRpc("pane.report_agent_session", { pane_id: paneId, agent: "copilot", source, seq: Date.now() * 1000 + seq, agent_session_id: session }); @@ -82,6 +88,7 @@ it.skipIf(process.platform !== "linux")("binds only the reported UUID to the for const first = await read(); expect(first.source).toBe("copilot-transcript"); expect(first.history_id).not.toBe(pending.history_id); + expect(first.history_id).not.toBe(external.history_id); expect(first.turns[0]?.parts[0]?.text).toBe("session A"); appendFileSync(file(id), event("assistant.message", { content: "Checking.", phase: "commentary", toolRequests: [{ toolCallId: "call-1", name: "shell", arguments: { command: "pwd" } }], model: "fixture-model" })); @@ -156,6 +163,7 @@ it.skipIf(process.platform !== "linux")("binds only the reported UUID to the for writeFileSync(file(other), fixture(id, "wrong header")); expect((await read()).source).toBe("scrollback"); rmSync(file(other)); + expect((await read()).source).toBe("scrollback"); symlinkSync(file(id), file(other)); expect((await read()).source).toBe("scrollback"); }); diff --git a/server/copilot.test.ts b/server/copilot.test.ts index bb741731fb..a68e9558f8 100644 --- a/server/copilot.test.ts +++ b/server/copilot.test.ts @@ -3,7 +3,7 @@ import { appendFileSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileS import { tmpdir } from "node:os"; import { join } from "node:path"; import { parseCopilotTranscript } from "./copilot.ts"; -import { copilotTranscriptFile, heldSessions } from "./copilot-store.ts"; +import { copilotTranscriptFile, copilotUnwrittenSession, heldSessions } from "./copilot-store.ts"; import { ConversationUnavailable, forgetTranscriptState, HistoryChanged, transcriptPage, transcriptToolOutput } from "./conversation.ts"; import { parseConversationMetadata } from "./conversation-metadata.ts"; @@ -211,14 +211,27 @@ it.skipIf(process.platform !== "linux")("uses only the process home and a canoni expect(copilotTranscriptFile(session, null, ["copilot"], pid)).toBeNull(); expect(copilotTranscriptFile("../bad", [`COPILOT_HOME=${custom}`], ["copilot"], pid)).toBeNull(); expect(copilotTranscriptFile(session, [`COPILOT_HOME=relative`], ["copilot"], pid)).toBeNull(); + expect(copilotUnwrittenSession([`COPILOT_HOME=${custom}`], ["copilot"], pid)).toBeNull(); writeFileSync(chosen, start.replace(session, "12aa83d8-48bc-41f2-a498-7055c5c44a02")); expect(copilotTranscriptFile(session, [`COPILOT_HOME=${custom}`], ["copilot"], pid)).toBeNull(); + expect(copilotUnwrittenSession([`COPILOT_HOME=${custom}`], ["copilot"], pid)).toBeNull(); + rmSync(chosen); + expect(copilotUnwrittenSession([`COPILOT_HOME=${custom}`], ["copilot"], pid)).toEqual({ sessionId: session, path: chosen }); + expect(copilotUnwrittenSession(null, ["copilot"], pid)).toBeNull(); + expect(copilotUnwrittenSession([`COPILOT_HOME=relative`], ["copilot"], pid)).toBeNull(); + for (const args of [["--resume", session], [`--resume=${session}`], ["--continue"], ["-r", session]]) { + expect(copilotUnwrittenSession([`COPILOT_HOME=${custom}`], ["copilot", ...args], pid)).toBeNull(); + } + symlinkSync(join(root, "missing.jsonl"), chosen); + expect(copilotUnwrittenSession([`COPILOT_HOME=${custom}`], ["copilot"], pid)).toBeNull(); rmSync(chosen); symlinkSync(legacyFile, chosen); expect(copilotTranscriptFile(session, [`COPILOT_HOME=${custom}`], ["copilot"], pid)).toBeNull(); + expect(copilotUnwrittenSession([`COPILOT_HOME=${custom}`], ["copilot"], pid)).toBeNull(); rmSync(chosen); mkdirSync(chosen); expect(copilotTranscriptFile(session, [`COPILOT_HOME=${custom}`], ["copilot"], pid)).toBeNull(); + expect(copilotUnwrittenSession([`COPILOT_HOME=${custom}`], ["copilot"], pid)).toBeNull(); rmSync(chosen, { recursive: true }); expect(await Bun.spawn(["mkfifo", chosen], { stdout: "ignore", stderr: "inherit" }).exited).toBe(0); expect(copilotTranscriptFile(session, [`COPILOT_HOME=${custom}`], ["copilot"], pid)).toBeNull(); @@ -228,7 +241,13 @@ it.skipIf(process.platform !== "linux")("uses only the process home and a canoni mkdirSync(join(custom, "session-state", extra)); await command("extra"); expect(copilotTranscriptFile(session, [`COPILOT_HOME=${custom}`], ["copilot"], pid)).toBeNull(); + rmSync(chosen); + expect(copilotUnwrittenSession([`COPILOT_HOME=${custom}`], ["copilot"], pid)).toBeNull(); await command("close"); + expect(copilotUnwrittenSession([`COPILOT_HOME=${custom}`], ["copilot"], pid)).toEqual({ sessionId: session, path: chosen }); + writeFileSync(chosen, ""); + expect(copilotUnwrittenSession([`COPILOT_HOME=${custom}`], ["copilot"], pid)).toBeNull(); + writeFileSync(chosen, start); expect(copilotTranscriptFile(session, [`COPILOT_HOME=${custom}`], ["copilot"], pid)).toBe(chosen); expect(copilotTranscriptFile(session, [`HOME=${defaultHome}`], ["copilot"], pid)).toBe(defaultFile); expect(copilotTranscriptFile(session, [`HOME=${defaultHome}`, `COPILOT_HOME=${custom}`], ["copilot", "--config-dir", legacy], pid)).toBe(legacyFile); From eda8a07c0a269dfc0944a8ec4918ee15dd0f166a Mon Sep 17 00:00:00 2001 From: Yu Xie Date: Thu, 22 Oct 2026 14:40:12 +0200 Subject: [PATCH 04/25] fix(tests): preserve updated upstream browser lane expectations Retain the current upstream browser scripts when adding the independent Copilot native-chat regression. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1fad73aa-cbc5-42dd-916b-64e5198bc512 --- scripts/ci-browser.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/scripts/ci-browser.test.ts b/scripts/ci-browser.test.ts index af000a5a29..98fac9f114 100644 --- a/scripts/ci-browser.test.ts +++ b/scripts/ci-browser.test.ts @@ -74,8 +74,6 @@ describe("browser lane", () => { expect(rows(text).map((row) => row.script)).toContain("scripts/replay-clipboard-demo-regression.ts"); expect(rows(text).map((row) => row.script)).toContain("scripts/theme-first-paint-demo-regression.ts"); expect(rows(text).map((row) => row.script)).toContain("scripts/copilot-chat-browser-qa.ts"); - expect(rows(text).map((row) => row.script)).toContain("scripts/attention-jump-demo-regression.ts"); - expect(rows(text).map((row) => row.script)).toContain("scripts/tool-copy-demo-regression.ts"); expect(rows(text).map((row) => row.script)).toContain("scripts/opencode-prompt-regression.ts"); expect(rows(text).map((row) => row.script)).toContain("scripts/session-history-browser-qa.ts"); expect(rows(text).map((row) => row.script)).toContain("scripts/command-outcome-demo-regression.ts"); From 4bd634563f1331eba1a98196ec1ec5034a8f549a Mon Sep 17 00:00:00 2001 From: Yu Xie Date: Thu, 22 Oct 2026 14:50:02 +0200 Subject: [PATCH 05/25] test(chat): isolate Copilot browser fixture configuration Use owned Herdr configuration and an empty usage service instead of the host configuration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1fad73aa-cbc5-42dd-916b-64e5198bc512 --- scripts/copilot-chat-browser-qa.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/copilot-chat-browser-qa.ts b/scripts/copilot-chat-browser-qa.ts index e14993e067..bddf30093d 100644 --- a/scripts/copilot-chat-browser-qa.ts +++ b/scripts/copilot-chat-browser-qa.ts @@ -7,6 +7,7 @@ import { join } from "node:path"; import { chromium } from "playwright-core"; import { createServer } from "../server/index.ts"; import { herdrRpc, sessionSnapshot, workspaceClose, workspaceCreate } from "../server/herdr/client.ts"; +import { UsageService } from "../server/usage.ts"; import { chromePath } from "./chrome-path.ts"; if (process.platform !== "linux") { @@ -67,7 +68,8 @@ console.log("fixture ready");`); } await herdrRpc("pane.report_agent_session", { pane_id: pane, agent: "copilot", source: "herdr:copilot", agent_session_id: id, seq: Date.now() * 1000 }); - server = createServer({ hostname: "127.0.0.1", port: 0, token: "", stateDir: join(root, "state"), tailscaleOwner: null }); + server = createServer({ hostname: "127.0.0.1", port: 0, token: "", stateDir: join(root, "state"), + herdrConfigPath: join(root, "config.toml"), usage: new UsageService(undefined, []), tailscaleOwner: null }); const endpoint = `http://127.0.0.1:${server.port}/api/pane/conversation?pane_id=${encodeURIComponent(pane)}`; const body = await (await fetch(endpoint)).json() as { source: string }; assert.equal(body.source, "copilot-transcript"); From 8548fd334d9f9ed6090129de732c05312ad3af88 Mon Sep 17 00:00:00 2001 From: Yu Xie Date: Thu, 22 Oct 2026 16:42:24 +0200 Subject: [PATCH 06/25] fix(chat): address Copilot transcript review findings Guard Linux-only contract setup on unsupported platforms and preserve the latest assistant activity timestamp through final answers and turn-end events. Add focused timestamp regressions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1fad73aa-cbc5-42dd-916b-64e5198bc512 --- server/copilot.contract.test.ts | 1 + server/copilot.test.ts | 28 ++++++++++++++++++++++++++++ server/copilot.ts | 3 ++- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/server/copilot.contract.test.ts b/server/copilot.contract.test.ts index f14b8e711d..52c0b71cd7 100644 --- a/server/copilot.contract.test.ts +++ b/server/copilot.contract.test.ts @@ -23,6 +23,7 @@ let workspace: string; let pane: string; beforeAll(async () => { + if (process.platform !== "linux") return; for (const session of [id, other]) mkdirSync(join(home, "session-state", session), { recursive: true }); writeFileSync(executable, `import { openSync, mkdirSync, writeFileSync, appendFileSync } from "node:fs"; import { join } from "node:path"; diff --git a/server/copilot.test.ts b/server/copilot.test.ts index a68e9558f8..6c992a3898 100644 --- a/server/copilot.test.ts +++ b/server/copilot.test.ts @@ -65,6 +65,34 @@ it("keeps unphased completed prose available as an answer, and explicit commenta expect(turns.at(-1)?.parts).toEqual([{ kind: "text", text: "work", phase: "commentary" }, { kind: "text", text: "unphased answer" }]); }); +it.each(["commentary", "final_answer", undefined])("tracks the latest assistant message timestamp with phase %s", (phase) => { + const stamp = "2026-10-21T00:00:05Z"; + const transcript = start + prompt("question") + answer("Working.", "commentary") + + JSON.stringify({ type: "assistant.message", timestamp: stamp, data: { content: "Latest message.", phase } }) + "\n"; + expect(parseCopilotTranscript(transcript).at(-1)).toMatchObject({ + ts: "2026-10-21T00:00:00Z", end_ts: stamp, + }); + const missingTimestamp = JSON.stringify({ type: "assistant.turn_end", data: {}, timestamp: null }) + "\n"; + expect(parseCopilotTranscript(transcript + missingTimestamp).at(-1)?.end_ts).toBe(stamp); +}); + +it("advances activity time past intermediate tool rounds and the final answer without changing turn boundaries", () => { + const at = (type: string, data: object, second: number) => + JSON.stringify({ type, data, timestamp: `2026-10-21T00:00:0${second}Z` }) + "\n"; + const work = start + prompt("question") + at("assistant.message", { content: "Working.", phase: "commentary" }, 1) + + at("assistant.turn_end", {}, 2); + const final = work + at("assistant.message", { content: "Done.", phase: "final_answer" }, 3); + const completed = final + at("assistant.turn_end", {}, 4); + expect(parseCopilotTranscript(work).at(-1)?.end_ts).toBe("2026-10-21T00:00:02Z"); + expect(parseCopilotTranscript(final).at(-1)?.end_ts).toBe("2026-10-21T00:00:03Z"); + expect(parseCopilotTranscript(completed).at(-1)?.end_ts).toBe("2026-10-21T00:00:04Z"); + const turns = parseCopilotTranscript(completed + at("user.message", { content: "Next question." }, 5) + + at("assistant.turn_end", {}, 6)); + expect(turns.map(({ role }) => role)).toEqual(["user", "assistant", "user"]); + expect(turns[1]?.end_ts).toBe("2026-10-21T00:00:04Z"); + expect(turns[2]?.end_ts).toBeUndefined(); +}); + it("pages bounded native turns and observes append, torn lines, and same-file resume without reset", () => { const dir = mkdtempSync(join(tmpdir(), "copilot-page-")); const path = join(dir, "events.jsonl"); diff --git a/server/copilot.ts b/server/copilot.ts index 72a64fe88e..8122f0c0b6 100644 --- a/server/copilot.ts +++ b/server/copilot.ts @@ -55,6 +55,7 @@ export function parseCopilotTranscript(jsonl: string, maxTurns = MAX_TURNS): Con const requests = Array.isArray(data.toolRequests) ? data.toolRequests : []; if (content === null && requests.length === 0) continue; const turn = assistant(timestamp); + if (timestamp !== null) turn.end_ts = timestamp; if (content !== null) turn.parts.push({ kind: "text", text: content, ...(data.phase === "final_answer" || data.phase === "commentary" ? { phase: data.phase } : {}), @@ -94,7 +95,7 @@ export function parseCopilotTranscript(jsonl: string, maxTurns = MAX_TURNS): Con pending.delete(id); } else if (type === "assistant.turn_end") { const last = turns.at(-1); - if (last?.role === "assistant" && !finished && timestamp !== null) last.end_ts = timestamp; + if (last?.role === "assistant" && timestamp !== null) last.end_ts = timestamp; } } return turns.filter((turn) => turn.parts.length > 0).slice(-maxTurns); From fc1902d760228b7158759bd19936b9b1dcd1add3 Mon Sep 17 00:00:00 2001 From: Yu Xie Date: Thu, 22 Oct 2026 17:32:41 +0000 Subject: [PATCH 07/25] fix(chat): Follow verified Copilot foreground sessions and render completions Bind transcript history to a process-owned lifecycle log when available, retaining the trusted marker/report fallback. Present persisted task-completion summaries and recorded user-choice questions without sending unverified prompt answers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 064cd7db-0ee7-4c99-ab55-74aad409d4b1 --- changelog.d/copilot-native-chat.md | 10 ++- docs/guide.md | 15 +++-- scripts/copilot-chat-browser-qa.ts | 22 +++++-- server/conversation.ts | 46 ++++++++++--- server/copilot-store.ts | 95 +++++++++++++++++++++++++-- server/copilot.contract.test.ts | 100 ++++++++++++++++++++++++++++- server/copilot.test.ts | 94 ++++++++++++++++++++++++++- server/copilot.ts | 20 ++++++ src/lib/askQuestions.test.ts | 12 ++++ src/lib/askQuestions.ts | 12 +++- 10 files changed, 393 insertions(+), 33 deletions(-) diff --git a/changelog.d/copilot-native-chat.md b/changelog.d/copilot-native-chat.md index fd736346b3..9bb121d5db 100644 --- a/changelog.d/copilot-native-chat.md +++ b/changelog.d/copilot-native-chat.md @@ -1,4 +1,8 @@ ### Added -- Copilot CLI conversations can show native text, tool activity and recorded model in chat on Linux - when Herdr's session ID matches the foreground process's sole held session; uncertain identity - or an in-process session switch uses Terminal instead. +- Copilot CLI conversations can show native text, tool activity, recorded model, question choices + and Autopilot completion summaries in chat on Linux when the foreground session is verified. + Answer interactive questions in Terminal. +### Fixed +- Resumed Copilot conversations show their history and follow in-process session switches when + the foreground process's lifecycle log proves the selection, even with older sessions open. + Unverifiable sessions fall back to Terminal instead of showing another session's history. diff --git a/docs/guide.md b/docs/guide.md index 01456bd151..60ae7d4f60 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -156,7 +156,7 @@ Every agent herdr runs shows up with its live status, terminal and alerts. The c | --- | --- | --- | | **Claude Code** | Native transcript, resolved through herdr | ✓ approvals, questions, plan and menu picks, and the `/model` list (picked for the session) | | **Codex** | Native rollout, with tool results and commentary/final phases | ✓ approvals and questions, including queued ones, and the `/model` lists (picked for the session) | -| **Copilot CLI** | Linux native `events.jsonl` when Herdr's Copilot `SessionStart` hook reports an exact UUID and the pane's unique foreground process holds **only that session** in its own `COPILOT_HOME` (otherwise its own `HOME/.copilot`). Follows the existing Codex-style chat presentation for text, tool calls/results and recorded model/effort. In the effective Copilot home (including any `COPILOT_HOME` or `--config-dir`), run `herdr integration install copilot` before starting `copilot` from a Herdr pane's **Terminal**. Ovis neither installs the hook automatically nor changes your home. This native path was verified on Linux with Copilot CLI 1.0.102-1 and Herdr 0.9.3. Herdr may keep the old ID after `/new`, `/clear` or in-process `/resume`; Copilot may also hold older sessions open. If the current session cannot be proved, chat uses Terminal instead of showing a guessed or stale session. Exit Copilot and start a fresh process (resume there if needed) to regain native chat after an ambiguous in-process switch. Missing hooks, ambiguous holds and macOS/Windows use Terminal. This is a reader of existing panes, not a Copilot option in **New workspace**. | — approvals and menus use Terminal | +| **Copilot CLI** | Linux native `events.jsonl` when the pane's foreground process has a verifiable open CLI log recording the current foreground UUID, or Herdr reports the UUID and that process holds its unique session marker. The process's own `COPILOT_HOME` (otherwise `HOME/.copilot`) supplies the transcript. This follows `/resume`, `/fork` and switch-back without selecting by recency, even when older sessions remain open; Copilot CLI 1.0.103 was observed emitting foreground lifecycle events. Text, tools, recorded model/effort, `ask_user` question choices and Autopilot completion summaries are shown. Run `herdr integration install copilot` in the effective Copilot home before starting a pane to enable the Herdr session report; Ovis never changes your CLI setup. If the process-owned log is missing, truncated, rotated without continuity, unreadable, or lacks a verifiable foreground registration, only an unambiguous marker **and** trusted Herdr report allow reading history. Otherwise Chat falls back to Terminal rather than showing another session; macOS/Windows use Terminal. Existing panes with neither proof cannot be recovered by a server restart. This reads existing panes, not a Copilot option in **New workspace**. | — recorded choices are read-only; answer interactive questions and approvals in Terminal | | **omp** | Native session file | ✓ approvals, questions, and the model picker of `/model` and `/switch` (picked for the session: the chat sends `/model` as `/switch`, since omp's own `/model` opens its role hub) | | **omo** | Native session file, found through the pane's process tree | ✓ the `/model` list (picked for the session); other menus — use Terminal | | **gjc** | Native session file identified by GJC's SDK broker and the pane's process | — use Terminal | @@ -169,11 +169,12 @@ Every agent herdr runs shows up with its live status, terminal and alerts. The c | **Antigravity (agy)** | **Experimental; macOS untested.** Native ledger, selected by the running process’s held conversation lock. Linux native-tested; Windows uses terminal text. See [Agy behavior and limits](agy-chat.md). | — use Terminal | | **Anything else** | The terminal's text | — use Terminal | -Before its first message, a new Linux Copilot process can show an empty chat even when started -from Terminal. The bridge verifies that the pane's process holds exactly one session in its own -Copilot home and that its transcript does not exist yet. Existing history still requires the -SessionStart hook; unreadable files, explicit resume/continue launches and ambiguous identities -keep the terminal fallback rather than appearing as an empty conversation. +Before its first message, a genuinely new Linux Copilot process can show an empty chat when its +process-owned log or unique held session marker identifies an unwritten session. An explicit +resume/continue without current foreground proof never gets a fresh-session placeholder. CLI +user-input requests are ephemeral, not persisted to the transcript: recorded `ask_user` choices +can be read in Chat, but live answers still belong to the CLI's Terminal; the bridge never sends +guessed selection keys or treats a question as an approval. GJC chat requires GJC 0.18.9 or later for its broker-bound SDK session CLI, with `gjc` on the server PC's PATH and its SDK broker already running. An older GJC opens in Terminal. @@ -294,7 +295,7 @@ Serve the app over **HTTPS** to install it and receive push alerts. The simplest tailscale serve --bg --https=443 http://127.0.0.1:7317 ``` -The one-line installer runs this for you when Tailscale runs on the PC and does not serve the app yet, on the first free port of 443, 8443, 7317 and 17317, and prints the command that undoes it. To keep 443 for something else, name the port in `OVIS_TAILSCALE_HTTPS_PORT` (see [Configuration](#configuration)), or set it for the installer's run alone: `curl -fsSL https://ovis.sh/install.sh | OVIS_TAILSCALE_HTTPS_PORT=9443 sh`. On Linux, `tailscale serve` needs root or `sudo tailscale set --operator=$USER` once; the installer says so when Tailscale refuses. +The one-line installer runs this for you when Tailscale runs on the PC and does not serve the app yet, on the first free port of 443, 8443, 7317 and 17317, and prints the command that undoes it. To keep 443 for something else, name the port in `OVIS_TAILSCALE_HTTPS_PORT` (see [Configuration](#configuration)), or set it for the installer's run alone: `curl -fsSL https://ovis.sh/install.sh | OVIS_TAILSCALE_HTTPS_PORT=9443 sh`. On Linux, `tailscale serve` needs root or `sudo tailscale set --operator="$(id -un)"` once; the installer says so when Tailscale refuses. An address that already serves the app is kept as it is. To move it to another port, turn the old one off (`tailscale serve --https=443 off`), then run the installer again. The installed app on a phone stays on the address it was installed from, so install it again from the new one. diff --git a/scripts/copilot-chat-browser-qa.ts b/scripts/copilot-chat-browser-qa.ts index bddf30093d..447d4899cd 100644 --- a/scripts/copilot-chat-browser-qa.ts +++ b/scripts/copilot-chat-browser-qa.ts @@ -34,9 +34,11 @@ try { writeFileSync(transcript, event("session.start", { sessionId: id, copilotVersion: "1.0.102-1" }) + event("user.message", { content: "A native Copilot prompt", transformedContent: "not visible", parentAgentTaskId: "root-task-id" }) + event("assistant.message", { content: "Looking up the answer.", phase: "commentary", model: "fixture-model", - toolRequests: [{ toolCallId: "fixture-call", name: "shell", arguments: { command: "pwd" }, toolTitle: "Check folder" }] }) + toolRequests: [{ toolCallId: "fixture-call", name: "shell", arguments: { command: "pwd" }, toolTitle: "Check folder" }, + { toolCallId: "ask-call", name: "ask_user", arguments: { question: "Proceed with this choice?", choices: ["Yes", "No"] }, toolTitle: "Ask user" }] }) + event("tool.execution_start", { toolCallId: "fixture-call", toolName: "shell", arguments: { command: "pwd" } }) + event("tool.execution_complete", { toolCallId: "fixture-call", success: true, result: { content: output } }) + + event("tool.execution_complete", { toolCallId: "ask-call", success: true, result: { content: "Yes" } }) + event("assistant.message", { content: "Native Copilot answer.", phase: "final_answer", model: "fixture-model" })); // A Node script named copilot is a genuine foreground process with a PID-held native // marker; its stdin remains the pane's PTY so the regular composer can reach it. @@ -80,23 +82,33 @@ console.log("fixture ready");`); page.on("pageerror", (error) => errors.push(error.message)); page.setDefaultTimeout(15_000); await page.addInitScript((paneId) => { - localStorage.setItem("herdr-web-ui:settings", JSON.stringify({ language: "en" })); - localStorage.setItem(`herdr-web-ui:view:${paneId}`, "chat"); + localStorage.setItem("ovis:settings", JSON.stringify({ language: "en" })); + localStorage.setItem(`ovis:view:${paneId}`, "chat"); }, pane); await page.goto(`http://127.0.0.1:${server.port}/?pane=${encodeURIComponent(pane)}`); const log = page.locator(`.pane-frame[data-layout-pane="${pane}"]`).getByRole("log"); await log.getByText("Native Copilot answer.", { exact: true }).waitFor(); await log.getByText("A native Copilot prompt", { exact: true }).waitFor(); assert.equal((await log.innerText()).includes("not visible"), false); - const head = log.locator(".work-block-head"); + const head = log.locator(".work-block-head").first(); if (await head.getAttribute("aria-expanded") !== "true") await head.click(); - await log.locator(".work-row-head").click(); + await log.locator(".work-row-head").first().click(); await log.locator(".chat-tool-more").click(); await log.getByText(/COPILOT_OUTPUT_END/).waitFor(); + await log.locator(".work-row-head").nth(1).click(); + await log.getByText("Proceed with this choice?").waitFor(); + await log.locator(".chat-ask-options").getByText("Yes", { exact: true }).waitFor(); + await log.locator(".chat-ask-options").getByText("No", { exact: true }).waitFor(); appendFileSync(transcript, event("session.resume", { selectedModel: "fixture-model" }) + event("user.message", { content: "Follow-up prompt" }) + event("assistant.message", { content: "Follow-up answer.", phase: "final_answer", model: "fixture-model" })); await log.getByText("Follow-up answer.", { exact: true }).waitFor(); + appendFileSync(transcript, event("user.message", { content: "Finish task" }) + + event("assistant.message", { toolRequests: [{ toolCallId: "finish-call", name: "task_complete", + arguments: { summary: "Autopilot finished this task." } }] }) + + event("tool.execution_complete", { toolCallId: "finish-call", success: true, result: { content: "done" } }) + + event("session.task_complete", { summary: "Autopilot finished this task." })); + await log.getByText("Autopilot finished this task.", { exact: true }).waitFor(); // Capture readable conversation, not a 6 KiB expanded tool detail. if (await head.getAttribute("aria-expanded") === "true") await head.click(); if (process.env.UI_EVIDENCE_DIR) { diff --git a/server/conversation.ts b/server/conversation.ts index adf0b2bc23..8270c58a0c 100644 --- a/server/conversation.ts +++ b/server/conversation.ts @@ -59,7 +59,7 @@ import { withinRetentionBudget } from "./retention-budget.ts"; import { forgetAllPiIndexes, forgetPiIndex, piAbandonedTurns, piBranchRecords, piBranchSegments } from "./pi-tree.ts"; import { trimOutput } from "./tool-output.ts"; import { copilotOutput, copilotRootRecord, parseCopilotTranscript } from "./copilot.ts"; -import { copilotTranscriptFile, copilotUnwrittenSession, openCopilotFile } from "./copilot-store.ts"; +import { copilotExplicitResume, copilotLoggedSession, copilotTranscriptFile, copilotUnwrittenSession, openCopilotFile } from "./copilot-store.ts"; import { parseConversationMetadata } from "./conversation-metadata.ts"; import { forgetHermesTranscriptState, hermesConversationPage, hermesToolOutput, hermesTranscriptForPane } from "./hermes.ts"; @@ -1572,11 +1572,24 @@ async function ompTranscriptPath(paneId: string, cwd: string): Promise { /** History requires Herdr's hook; a process-held unwritten session can show an empty chat. */ async function copilotTranscriptPath(pane: HerdrPane, panes: readonly HerdrPane[]): Promise { const report = pane.agent_session; - const claimedByPeer = (session: string) => panes.some((peer) => peer.pane_id !== pane.pane_id - && peer.agent_session?.agent === "copilot" && peer.agent_session.kind === "id" && peer.agent_session.value === session); + const claimedByPeer = async (session: string): Promise => { + 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 processes = agentProcesses(info?.foreground_processes ?? [], "copilot"); + if (processes.length === 1 && typeof processes[0]?.pid === "number") { + const live = copilotLoggedSession(processEnviron(processes[0].pid), processes[0].argv ?? [], processes[0].pid); + if (live !== null) { + if (live === session) return true; + continue; + } + } + if (peer.agent_session?.agent === "copilot" && peer.agent_session.kind === "id" && peer.agent_session.value === session) return true; + } + return false; + }; if (pane.agent !== "copilot" || (report != null && (report.agent !== "copilot" || report.kind !== "id" - || report.source !== "herdr:copilot" || typeof report.value !== "string" || !SESSION_ID.test(report.value) - || claimedByPeer(report.value)))) { + || report.source !== "herdr:copilot" || typeof report.value !== "string" || !SESSION_ID.test(report.value)))) { throw new ConversationUnavailable("no_session_id"); } const info = await paneProcessInfo(pane.pane_id).catch(() => null); @@ -1587,15 +1600,24 @@ async function copilotTranscriptPath(pane: HerdrPane, panes: readonly HerdrPane[ } const environment = processEnviron(foreground.pid); const argv = foreground.argv ?? []; + const logged = copilotLoggedSession(environment, argv, foreground.pid); + if (logged !== null && !(await claimedByPeer(logged))) { + const path = copilotTranscriptFile(logged, environment, argv, foreground.pid); + if (path !== null && nodePath.basename(nodePath.dirname(path)) === logged) return path; + } if (report != null) { - const path = copilotTranscriptFile(report.value, environment, argv, foreground.pid); - if (path !== null) return path; + if (await claimedByPeer(report.value)) throw new ConversationUnavailable("no_session_id"); + if (logged === null) { + const path = copilotTranscriptFile(report.value, environment, argv, foreground.pid); + if (path !== null && nodePath.basename(nodePath.dirname(path)) === report.value) return path; + } } const pending = copilotUnwrittenSession(environment, argv, foreground.pid); - if (pending !== null && (report == null || report.value === pending.sessionId) && !claimedByPeer(pending.sessionId)) { + if (pending !== null && (report == null || logged === pending.sessionId || report.value === pending.sessionId) + && !(await claimedByPeer(pending.sessionId))) { throw new ConversationNotStarted(pending.sessionId, "copilot-transcript", pending.path); } - throw new ConversationUnavailable(report == null ? "no_session_id" : "no_session_path"); + throw new ConversationUnavailable(copilotExplicitResume(argv) ? "copilot_resume_unknown" : report == null ? "no_session_id" : "no_session_path"); } /** Where a pane's conversation is: a transcript file, or a session in OpenCode's database. */ @@ -1835,6 +1857,12 @@ export async function paneConversation(paneId: string, codexHome?: string, page: const pane = snapshot.panes.find((candidate) => candidate.pane_id === paneId); if (pane === undefined) { launchedAgents.forget(paneId); throw error; } if (pane.agent === "copilot" && (page.before !== undefined || page.since !== undefined || page.from !== undefined)) throw new HistoryChanged(); + if (pane.agent === "copilot" && error.message === "copilot_resume_unknown") throw error; + if (pane.agent === "copilot") { + const info = await paneProcessInfo(paneId).catch(() => null); + const processes = agentProcesses(info?.foreground_processes ?? [], "copilot"); + if (processes.length !== 1 || copilotExplicitResume(processes[0]?.argv ?? [])) throw error; + } // an agent this server started writes no session before its first message (launched-agents.ts) const launch = launchedAgents.notBegun(paneId, pane.agent_status); if (launch === null) { diff --git a/server/copilot-store.ts b/server/copilot-store.ts index a86e8e8ed6..0ecffc951c 100644 --- a/server/copilot-store.ts +++ b/server/copilot-store.ts @@ -4,6 +4,65 @@ import { basename, dirname, isAbsolute, join, relative, sep } from "node:path"; const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const HEADER_BYTES = 64 * 1024; const MAX_DESCRIPTORS = 4096; +const LOG_WINDOW_BYTES = 16 * 1024 * 1024; +const LOG_NAME = /^process-\d+-(\d+)\.log$/; +const FOREGROUND_EVENT = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+Z \[INFO\] (Registering|Unregistering) foreground session: ([\da-f-]{36})$/; +const foregroundCheckpoints = new Map(); + +/** The latest complete lifecycle event in the CLI's own open log, never the newest transcript. */ +function loggedForeground(pid: number, logDirectories: readonly string[]): string | null | undefined { + const directory = opendirSync(`/proc/${pid}/fd`); + let found: string | null = null; + let count = 0; + try { + for (let descriptor = directory.readSync(); descriptor; descriptor = directory.readSync()) { + if (++count > MAX_DESCRIPTORS) return undefined; + let target: string; + try { target = readlinkSync(`/proc/${pid}/fd/${descriptor.name}`); } + catch { return undefined; } + if (target.endsWith(" (deleted)") && LOG_NAME.test(basename(target.slice(0, -10)))) return undefined; + if (!logDirectories.some((path) => dirname(target) === path) || LOG_NAME.exec(basename(target))?.[1] !== String(pid)) continue; + if (found !== null) return undefined; + try { + if (realpathSync(target) !== target) return undefined; + const fd = openSync(`/proc/${pid}/fd/${descriptor.name}`, constants.O_RDONLY | constants.O_NONBLOCK); + try { + const stat = fstatSync(fd); + const named = statSync(target); + if (!stat.isFile() || stat.dev !== named.dev || stat.ino !== named.ino) return undefined; + const cached = foregroundCheckpoints.get(pid); + const same = cached?.dev === stat.dev && cached.ino === stat.ino && cached.path === target && stat.size >= cached.size; + const start = same ? cached.size : Math.max(0, stat.size - LOG_WINDOW_BYTES); + if (stat.size - start > LOG_WINDOW_BYTES) return undefined; + const bytes = Buffer.alloc(stat.size - start); + const read = readSync(fd, bytes, 0, bytes.length, start); + if (read !== bytes.length || fstatSync(fd).size !== stat.size) return undefined; + const text = bytes.subarray(0, read).toString("utf8"); + if (text && !text.endsWith("\n")) return undefined; + const complete = text.slice(start === 0 ? 0 : text.indexOf("\n") + 1, text.lastIndexOf("\n") + 1); + if (start > 0 && !complete && !same) return undefined; + let selected: string | null = same ? cached.session : null; + let observed = false; + for (const line of complete.split("\n")) { + const match = FOREGROUND_EVENT.exec(line); + if (!match) continue; + observed = true; + if (!SESSION_ID.test(match[2]!)) return undefined; + if (match[1] === "Registering") selected = match[2]!; + else if (selected === match[2]) selected = null; + else return undefined; + } + if (!observed && !same) return undefined; + if (selected === null) return undefined; + if (foregroundCheckpoints.size >= 256 && !foregroundCheckpoints.has(pid)) foregroundCheckpoints.delete(foregroundCheckpoints.keys().next().value!); + foregroundCheckpoints.set(pid, { dev: stat.dev, ino: stat.ino, path: target, size: stat.size, session: selected }); + found = selected; + } finally { closeSync(fd); } + } catch { return undefined; } + } + } finally { directory.closeSync(); } + return found; +} /** Pin one regular canonical file; callers must close the descriptor after reading. */ export function openCopilotFile(file: string): number { @@ -72,7 +131,7 @@ export function heldSessions(pid: number, store: string): Set | null { return null; } -function processSessionFile(environment: readonly string[] | null, argv: readonly string[], pid: number): { sessionId: string; path: string } | null { +function processSessionFile(environment: readonly string[] | null, argv: readonly string[], pid: number): { sessionId: string; path: string; logged: boolean } | null { if (environment === null || !Number.isSafeInteger(pid) || pid < 1) return null; const values = (key: string) => environment.filter((item) => item.startsWith(`${key}=`)).map((item) => item.slice(key.length + 1)); const home = values("HOME"); @@ -93,20 +152,34 @@ function processSessionFile(environment: readonly string[] | null, argv: readonl if (!statSync(canonicalHome).isDirectory()) return null; const store = join(canonicalHome, "session-state"); if (realpathSync(store) !== store) return null; + const logOptions: string[] = []; + for (let i = 1; i < argv.length; i++) { + if (argv[i] === "--") break; + if (argv[i] === "--log-dir") logOptions.push(argv[++i] ?? ""); + else if (argv[i]?.startsWith("--log-dir=")) logOptions.push(argv[i]!.slice("--log-dir=".length)); + } + if (logOptions.length > 1 || (logOptions.length && !isAbsolute(logOptions[0]!))) return null; + const logs = logOptions.length ? logOptions : [join(canonicalHome, "logs"), ...(home[0] ? [join(home[0], ".copilot", "logs")] : [])]; + const foreground = loggedForeground(pid, logs); + if (foreground === undefined) return null; + if (foreground !== null) { + const directory = join(store, foreground); + if (realpathSync(directory) !== directory) return null; + return { sessionId: foreground, path: join(directory, "events.jsonl"), logged: true }; + } const held = heldSessions(pid, store); if (held === null || held.size !== 1) return null; const session = [...held][0]!; const directory = join(store, session); if (realpathSync(directory) !== directory) return null; - return { sessionId: session, path: join(directory, "events.jsonl") }; + return { sessionId: session, path: join(directory, "events.jsonl"), logged: false }; } catch { return null; } } /** Only the reported session, beneath the foreground process's own Copilot home. */ export function copilotTranscriptFile(session: unknown, environment: readonly string[] | null, argv: readonly string[], pid: number): string | null { - if (typeof session !== "string" || !SESSION_ID.test(session)) return null; const held = processSessionFile(environment, argv, pid); - if (held === null || held.sessionId !== session) return null; + if (held === null || (session === null ? !held.logged : typeof session !== "string" || !SESSION_ID.test(session) || held.sessionId !== session)) return null; try { const fd = openCopilotFile(held.path); try { @@ -115,15 +188,25 @@ export function copilotTranscriptFile(session: unknown, environment: readonly st } catch { return null; } } +/** A process-owned lifecycle log can supersede herdr's one-time session report. */ +export function copilotLoggedSession(environment: readonly string[] | null, argv: readonly string[], pid: number): string | null { + const found = processSessionFile(environment, argv, pid); + return found?.logged ? found.sessionId : null; +} + /** A process-held session with no transcript yet, never permission to read unreported history. */ export function copilotUnwrittenSession(environment: readonly string[] | null, argv: readonly string[], pid: number): { sessionId: string; path: string } | null { - if (argv.some((word) => word === "--continue" || word === "-r" || word === "--resume" || word.startsWith("--resume="))) return null; const held = processSessionFile(environment, argv, pid); if (held === null) return null; + if (!held.logged && copilotExplicitResume(argv)) return null; try { lstatSync(held.path); } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return held; + if ((error as NodeJS.ErrnoException).code === "ENOENT") return { sessionId: held.sessionId, path: held.path }; } return null; } + +export function copilotExplicitResume(argv: readonly string[]): boolean { + return argv.some((word) => word === "--continue" || word === "-r" || word === "--resume" || word.startsWith("--resume=")); +} diff --git a/server/copilot.contract.test.ts b/server/copilot.contract.test.ts index 52c0b71cd7..ec5dade19a 100644 --- a/server/copilot.contract.test.ts +++ b/server/copilot.contract.test.ts @@ -29,7 +29,14 @@ beforeAll(async () => { import { join } from "node:path"; const home = process.env.COPILOT_HOME; const hold = (id) => { const dir = join(home, "session-state", id); mkdirSync(dir, { recursive: true }); openSync(join(dir, \`inuse.\${process.pid}.hold\`), "w"); }; -hold(process.argv[2]); +const lifecycle = process.argv.includes("--lifecycle"); +const log = join(home, "logs", \`process-100-\${process.pid}.log\`); +let selected = process.argv[2]; +if (lifecycle) { + mkdirSync(join(home, "logs"), { recursive: true }); + openSync(log, "w"); + appendFileSync(log, new Date().toISOString() + " [INFO] Registering foreground session: " + selected + "\\n"); +} else if (!process.argv.includes("--no-proof")) hold(selected); process.stdin.setEncoding("utf8"); let buffer = ""; process.stdin.on("data", (chunk) => { @@ -37,6 +44,12 @@ process.stdin.on("data", (chunk) => { for (let at; (at = buffer.indexOf("\\n")) !== -1;) { const line = buffer.slice(0, at); buffer = buffer.slice(at + 1); if (line.startsWith(":hold ")) hold(line.slice(6)); + else if (line.startsWith(":switch ") && lifecycle) { + const next = line.slice(8); + appendFileSync(log, new Date().toISOString() + " [INFO] Unregistering foreground session: " + selected + "\\n"); + appendFileSync(log, new Date().toISOString() + " [INFO] Registering foreground session: " + next + "\\n"); + selected = next; + } else if (line === ":quit") process.exit(0); else appendFileSync(join(home, "input.txt"), line + "\\n"); } @@ -168,3 +181,88 @@ it.skipIf(process.platform !== "linux")("binds only the reported UUID to the for symlinkSync(file(id), file(other)); expect((await read()).source).toBe("scrollback"); }); + +it.skipIf(process.platform !== "linux")("reads a resumed CLI without marker or report and follows foreground switches rather than stale reports", async () => { + const resumed = "12aa83d8-48bc-41f2-a498-7055c5c44a03"; + const forked = "12aa83d8-48bc-41f2-a498-7055c5c44a04"; + for (const id of [resumed, forked]) { + mkdirSync(join(home, "session-state", id), { recursive: true }); + writeFileSync(file(id), fixture(id, id) + event("assistant.message", { + toolRequests: [{ toolCallId: `call-${id}`, name: "shell", arguments: {} }], + }) + event("tool.execution_complete", { toolCallId: `call-${id}`, result: { content: `output-${id}` } })); + } + const created = await workspaceCreate({ cwd: root, label: "herdr-web-ui-test-copilot-lifecycle" }); + const current = created.root_pane.pane_id; + try { + await herdrRpc("pane.send_text", { pane_id: current, text: `COPILOT_HOME=${home} node ${executable} ${resumed} --resume=${resumed} --lifecycle\n` }); + for (const deadline = Date.now() + 10_000;;) { + if ((await sessionSnapshot()).panes.find((p) => p.pane_id === current)?.agent === "copilot") break; + if (Date.now() > deadline) throw new Error("owned resumed fixture not detected"); + await Bun.sleep(50); + } + const url = `http://127.0.0.1:${app.port}/api/pane/conversation?pane_id=${encodeURIComponent(current)}`; + const read = async () => (await fetch(url)).json() as Promise<{ + source: string; turns: { parts: { text?: string }[] }[]; cursor?: string; history_id?: string; + }>; + const initial = await read(); + expect(initial.source).toBe("copilot-transcript"); + expect(initial.turns[0]?.parts[0]?.text).toBe(resumed); + expect(initial.history_id).not.toStartWith("unwritten:"); + const duplicate = await workspaceCreate({ cwd: root, label: "herdr-web-ui-test-copilot-log-peer" }); + try { + const peer = duplicate.root_pane.pane_id; + await herdrRpc("pane.send_text", { pane_id: peer, text: `COPILOT_HOME=${home} node ${executable} ${resumed} --resume=${resumed} --lifecycle\n` }); + for (const deadline = Date.now() + 10_000;;) { + if ((await sessionSnapshot()).panes.find((p) => p.pane_id === peer)?.agent === "copilot") break; + if (Date.now() > deadline) throw new Error("log-only duplicate fixture not detected"); + await Bun.sleep(50); + } + expect((await read()).source).toBe("scrollback"); + const peerUrl = `http://127.0.0.1:${app.port}/api/pane/conversation?pane_id=${encodeURIComponent(peer)}`; + expect((await (await fetch(peerUrl)).json()).source).toBe("scrollback"); + } finally { await workspaceClose(duplicate.workspace.workspace_id); } + expect((await read()).source).toBe("copilot-transcript"); + await herdrRpc("pane.report_agent_session", { pane_id: current, agent: "copilot", source: "herdr:copilot", + seq: Date.now() * 1000, agent_session_id: resumed }); + await herdrRpc("pane.send_text", { pane_id: current, text: `:hold ${resumed}\n:hold ${forked}\n:switch ${forked}\n` }); + for (const deadline = Date.now() + 5000;;) { + if ((await read()).turns[0]?.parts[0]?.text === forked) break; + if (Date.now() > deadline) throw new Error("logged fork was not selected"); + await Bun.sleep(50); + } + expect((await fetch(`${url}&from=${encodeURIComponent(initial.cursor ?? initial.history_id!)}`)).status).toBe(409); + const staleTool = new URLSearchParams({ pane_id: current, ref: `call-${resumed}` }); + expect((await fetch(`http://127.0.0.1:${app.port}/api/pane/conversation/tool-output?${staleTool}`)).status).toBe(404); + await herdrRpc("pane.send_text", { pane_id: current, text: `:switch ${resumed}\n` }); + for (const deadline = Date.now() + 5000;;) { + if ((await read()).turns[0]?.parts[0]?.text === resumed) break; + if (Date.now() > deadline) throw new Error("switch-back was not selected"); + await Bun.sleep(50); + } + } finally { + await workspaceClose(created.workspace.workspace_id); + } +}); + +it.skipIf(process.platform !== "linux")("never mistakes an explicitly resumed launch without current evidence for a fresh empty chat", async () => { + const resumed = "12aa83d8-48bc-41f2-a498-7055c5c44a05"; + mkdirSync(join(home, "session-state", resumed), { recursive: true }); + writeFileSync(file(resumed), fixture(resumed, "existing history")); + const created = await workspaceCreate({ cwd: root, label: "herdr-web-ui-test-copilot-no-proof" }); + const current = created.root_pane.pane_id; + try { + await herdrRpc("pane.send_text", { pane_id: current, text: `COPILOT_HOME=${home} node ${executable} ${resumed} --resume=${resumed} --no-proof\n` }); + for (const deadline = Date.now() + 10_000;;) { + if ((await sessionSnapshot()).panes.find((p) => p.pane_id === current)?.agent === "copilot") break; + if (Date.now() > deadline) throw new Error("owned no-proof fixture not detected"); + await Bun.sleep(50); + } + launchedAgents.launched(current, "copilot"); + const url = `http://127.0.0.1:${app.port}/api/pane/conversation?pane_id=${encodeURIComponent(current)}`; + expect((await (await fetch(url)).json()).source).toBe("scrollback"); + expect((await fetch(`${url}&from=stale`)).status).toBe(409); + } finally { + launchedAgents.forget(current); + await workspaceClose(created.workspace.workspace_id); + } +}); diff --git a/server/copilot.test.ts b/server/copilot.test.ts index 6c992a3898..b7366329cf 100644 --- a/server/copilot.test.ts +++ b/server/copilot.test.ts @@ -3,7 +3,7 @@ import { appendFileSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileS import { tmpdir } from "node:os"; import { join } from "node:path"; import { parseCopilotTranscript } from "./copilot.ts"; -import { copilotTranscriptFile, copilotUnwrittenSession, heldSessions } from "./copilot-store.ts"; +import { copilotLoggedSession, copilotTranscriptFile, copilotUnwrittenSession, heldSessions } from "./copilot-store.ts"; import { ConversationUnavailable, forgetTranscriptState, HistoryChanged, transcriptPage, transcriptToolOutput } from "./conversation.ts"; import { parseConversationMetadata } from "./conversation-metadata.ts"; @@ -65,6 +65,33 @@ it("keeps unphased completed prose available as an answer, and explicit commenta expect(turns.at(-1)?.parts).toEqual([{ kind: "text", text: "work", phase: "commentary" }, { kind: "text", text: "unphased answer" }]); }); +it("renders completed Autopilot summaries as final replies without duplicating an explicit answer or a child completion", () => { + const completion = (id: string, summary: string) => + line("assistant.message", { toolRequests: [{ toolCallId: id, name: "task_complete", arguments: { summary } }] }) + + line("tool.execution_complete", { toolCallId: id, success: true, result: { content: "Task complete." } }); + const transcript = start + prompt("first") + answer("Working", "commentary") + + completion("done-1", "Completed first task.") + + line("session.task_complete", { summary: "Completed first task." }) + + prompt("second") + completion("done-2", "Tool fallback.") + + line("session.task_complete", { summary: "Authoritative second summary." }) + + answer("Actual final reply.") + + line("session.task_complete", { summary: "Do not repeat the final reply." }) + + prompt("third") + line("assistant.message", { content: "child", phase: "commentary" }, "child") + + line("session.task_complete", { summary: "child secret", agentId: "child" }, "child") + + line("assistant.message", { toolRequests: [{ toolCallId: "failed", name: "task_complete", arguments: { summary: "failed" } }] }) + + line("tool.execution_complete", { toolCallId: "failed", success: false, result: { content: "failed" } }); + const turns = parseCopilotTranscript(transcript); + expect(turns[1]?.parts.filter((part) => part.kind === "text")).toEqual([ + { kind: "text", text: "Working", phase: "commentary" }, + { kind: "text", text: "Completed first task.", phase: "final_answer" }, + ]); + expect(turns[3]?.parts.filter((part) => part.kind === "text")).toEqual([ + { kind: "text", text: "Actual final reply.", phase: "final_answer" }, + ]); + expect(JSON.stringify(turns)).not.toContain("child secret"); + expect(JSON.stringify(turns)).not.toContain('"text":"failed"'); +}); + it.each(["commentary", "final_answer", undefined])("tracks the latest assistant message timestamp with phase %s", (phase) => { const stamp = "2026-10-21T00:00:05Z"; const transcript = start + prompt("question") + answer("Working.", "commentary") @@ -224,6 +251,7 @@ it.skipIf(process.platform !== "linux")("uses only the process home and a canoni console.log("ok"); } }); + console.log("ready"); `, custom, join(defaultHome, ".copilot"), legacy], { stdin: "pipe", stdout: "pipe", stderr: "inherit" }); stop = () => { child.kill(); return child.exited; }; @@ -284,3 +312,67 @@ it.skipIf(process.platform !== "linux")("uses only the process home and a canoni rmSync(root, { recursive: true, force: true }); } }); + +it.skipIf(process.platform !== "linux")("follows process-owned foreground evidence through fork and switch-back with multiple holds", async () => { + const root = mkdtempSync(join(tmpdir(), "copilot-foreground-")); + const home = join(root, "home"); + const logs = join(home, "logs"); + const other = "12aa83d8-48bc-41f2-a498-7055c5c44a02"; + const options = [`COPILOT_HOME=${home}`]; + const args = ["copilot", "--resume", session]; + for (const id of [session, other]) { + const directory = join(home, "session-state", id); + mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, "events.jsonl"), line("session.start", { sessionId: id }) + prompt(id)); + } + mkdirSync(logs); + const child = Bun.spawn(["node", "-e", ` + const { openSync, appendFileSync } = require("node:fs"); + const { join } = require("node:path"); + const root = process.argv[1]; + const ids = process.argv.slice(2); + const path = join(root, "logs", "process-100-" + process.pid + ".log"); + openSync(path, "w"); + for (const id of ids) openSync(join(root, "session-state", id, "inuse." + process.pid + ".hold"), "w"); + const event = (action, id) => appendFileSync(path, new Date().toISOString() + " [INFO] " + action + " foreground session: " + id + "\\n"); + event("Registering", ids[0]); + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (input) => { for (const command of input.trim().split("\\n")) { + if (command === "fork") { event("Unregistering", ids[0]); event("Registering", ids[1]); } + if (command === "back") { event("Unregistering", ids[1]); event("Registering", ids[0]); } + if (command === "incomplete") appendFileSync(path, new Date().toISOString() + " [INFO] Registering foreground session: " + ids[1]); + console.log("ok"); + } }); + console.log("ready"); + `, home, session, other], { stdin: "pipe", stdout: "pipe", stderr: "inherit" }); + const reader = child.stdout.getReader(); + try { + expect(new TextDecoder().decode((await reader.read()).value)).toContain("ready"); + const command = async (value: string) => { + child.stdin.write(`${value}\n`); + expect(new TextDecoder().decode((await reader.read()).value)).toContain("ok"); + }; + expect(heldSessions(child.pid, join(home, "session-state"))?.size).toBe(2); + expect(copilotLoggedSession(options, args, child.pid)).toBe(session); + expect(copilotTranscriptFile(null, options, args, child.pid)).toBe(join(home, "session-state", session, "events.jsonl")); + const log = join(logs, `process-100-${child.pid}.log`); + for (let n = 0; n < 17; n++) { + appendFileSync(log, `unrelated ${".".repeat(1024 * 1024)}\n`); + expect(copilotLoggedSession(options, args, child.pid)).toBe(session); + } + expect(copilotLoggedSession(options, args, child.pid)).toBe(session); + await command("fork"); + expect(copilotLoggedSession(options, args, child.pid)).toBe(other); + expect(copilotTranscriptFile(session, options, args, child.pid)).toBeNull(); + expect(copilotTranscriptFile(other, options, args, child.pid)).toBe(join(home, "session-state", other, "events.jsonl")); + await command("back"); + expect(copilotLoggedSession(options, args, child.pid)).toBe(session); + await command("incomplete"); + expect(copilotLoggedSession(options, args, child.pid)).toBeNull(); + expect(copilotTranscriptFile(session, options, args, child.pid)).toBeNull(); + } finally { + child.kill(); + await child.exited; + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/server/copilot.ts b/server/copilot.ts index 8122f0c0b6..40ca41d68d 100644 --- a/server/copilot.ts +++ b/server/copilot.ts @@ -33,7 +33,17 @@ export function copilotRecords(jsonl: string): { type: string; data: Row; timest export function parseCopilotTranscript(jsonl: string, maxTurns = MAX_TURNS): ConversationTurn[] { const turns: ConversationTurn[] = []; const pending = new Map>(); + const completionTools = new Map(); let finished = false; + let completion: string | null = null; + const flushCompletion = (): void => { + const last = turns.at(-1); + if (completion !== null && last?.role === "assistant" + && !last.parts.some((part) => part.kind === "text" && part.phase === "final_answer")) { + last.parts.push({ kind: "text", text: completion, phase: "final_answer" }); + } + completion = null; + }; const assistant = (ts: string | null): ConversationTurn => { const last = turns.at(-1); if (last?.role === "assistant" && !finished) return last; @@ -46,8 +56,10 @@ export function parseCopilotTranscript(jsonl: string, maxTurns = MAX_TURNS): Con if (type === "user.message") { const content = text(data.content); if (content !== null) { + flushCompletion(); turns.push({ role: "user", ts: timestamp, parts: [{ kind: "text", text: content }] }); pending.clear(); + completionTools.clear(); finished = false; } } else if (type === "assistant.message") { @@ -72,6 +84,7 @@ export function parseCopilotTranscript(jsonl: string, maxTurns = MAX_TURNS): Con }; turn.parts.push(part); pending.set(request.toolCallId, part); + if (name === "task_complete" && text(args.summary) !== null) completionTools.set(request.toolCallId, text(args.summary)!); } if (data.phase === "final_answer") finished = true; } else if (type === "tool.execution_start") { @@ -85,6 +98,7 @@ export function parseCopilotTranscript(jsonl: string, maxTurns = MAX_TURNS): Con }; assistant(timestamp).parts.push(part); pending.set(id, part); + if (name === "task_complete" && text(args.summary) !== null) completionTools.set(id, text(args.summary)!); } else if (type === "tool.execution_complete") { const id = data.toolCallId; const part = typeof id === "string" ? pending.get(id) : undefined; @@ -92,12 +106,18 @@ export function parseCopilotTranscript(jsonl: string, maxTurns = MAX_TURNS): Con const output = text(row(data.result)?.content) ?? ""; trimOutput(part, output, id); if (data.success === false) part.error = true; + if (data.success !== false && completionTools.has(id)) completion ??= completionTools.get(id)!; pending.delete(id); + completionTools.delete(id); + } else if (type === "session.task_complete") { + if (turns.length && turns.at(-1)?.role === "user") assistant(timestamp); + completion = text(data.summary) ?? completion; } else if (type === "assistant.turn_end") { const last = turns.at(-1); if (last?.role === "assistant" && timestamp !== null) last.end_ts = timestamp; } } + flushCompletion(); return turns.filter((turn) => turn.parts.length > 0).slice(-maxTurns); } diff --git a/src/lib/askQuestions.test.ts b/src/lib/askQuestions.test.ts index ae73acdee6..d551170459 100644 --- a/src/lib/askQuestions.test.ts +++ b/src/lib/askQuestions.test.ts @@ -31,6 +31,18 @@ describe("a past question tool call", () => { } }); + test("shows Copilot's recorded question and choices without offering an unverified answer path", () => { + expect(askedQuestions("ask_user", { question: "Proceed?", choices: ["Yes", "No"], allowFreeform: true })).toEqual([{ + header: null, question: "Proceed?", multi: false, recommended: null, options: [ + { label: "Yes", description: null, preview: null }, { label: "No", description: null, preview: null }, + ], + }]); + expect(askedQuestions("ask_user", { question: "What next?" })?.[0]?.options).toEqual([]); + for (const bad of [{ question: "" }, { question: "Proceed?", choices: [0] }, { question: "Proceed?", choices: [""] }]) { + expect(askedQuestions("ask_user", bad)).toBeNull(); + } + }); + test("ignores a recommendation that names no option", () => { for (const recommended of [2, -1, 0.5, "0"]) { expect(askedQuestions("ask", { questions: [{ question: "Q?", recommended, options: [{ label: "A" }, { label: "B" }] }] })?.[0]?.recommended).toBeNull(); diff --git a/src/lib/askQuestions.ts b/src/lib/askQuestions.ts index 1fbed64c14..434d536336 100644 --- a/src/lib/askQuestions.ts +++ b/src/lib/askQuestions.ts @@ -23,13 +23,23 @@ export interface AskedQuestion { options: AskedOption[]; } -const ASK_TOOLS: ReadonlySet = new Set(["ask", "ask_user_question", "request_user_input", "AskUserQuestion"]); +const ASK_TOOLS: ReadonlySet = new Set(["ask", "ask_user", "ask_user_question", "request_user_input", "AskUserQuestion"]); const optionalText = (value: unknown): value is string | null | undefined => value == null || typeof value === "string"; /** A question tool's questions, or null for another tool or arguments not in a shape it asks with. */ export function askedQuestions(tool: string, args: unknown): AskedQuestion[] | null { if (!ASK_TOOLS.has(tool) || args === null || typeof args !== "object") return null; + if (tool === "ask_user") { + const input = args as Record; + if (typeof input["question"] !== "string" || !input["question"].trim()) return null; + const choices = input["choices"]; + if (choices !== undefined && (!Array.isArray(choices) || choices.some((choice) => typeof choice !== "string" || !choice.trim()))) return null; + return [{ + header: null, question: input["question"], multi: false, recommended: null, + options: ((choices ?? []) as string[]).map((label) => ({ label, description: null, preview: null })), + }]; + } const questions = (args as Record)["questions"]; if (!Array.isArray(questions) || questions.length === 0) return null; const asked: AskedQuestion[] = []; From fd6050bd58b67c145a480579a618126ebbf1d777 Mon Sep 17 00:00:00 2001 From: Yu Xie Date: Thu, 22 Oct 2026 18:09:16 +0000 Subject: [PATCH 08/25] fix(copilot): Invalidate switchback history and tool references Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 064cd7db-0ee7-4c99-ab55-74aad409d4b1 --- changelog.d/copilot-native-chat.md | 3 ++ docs/guide.md | 5 ++ scripts/copilot-chat-browser-qa.ts | 2 +- server/conversation.ts | 79 +++++++++++++++++++++++++----- server/copilot-store.ts | 41 ++++++++++------ server/copilot.contract.test.ts | 64 ++++++++++++++++++++---- 6 files changed, 156 insertions(+), 38 deletions(-) diff --git a/changelog.d/copilot-native-chat.md b/changelog.d/copilot-native-chat.md index 9bb121d5db..fa8ca35396 100644 --- a/changelog.d/copilot-native-chat.md +++ b/changelog.d/copilot-native-chat.md @@ -6,3 +6,6 @@ - Resumed Copilot conversations show their history and follow in-process session switches when the foreground process's lifecycle log proves the selection, even with older sessions open. Unverifiable sessions fall back to Terminal instead of showing another session's history. +- Copilot pagination and tool-output links from a prior foreground selection no longer + become valid again after switching back; an unregister without a new registration + also stops serving the old conversation. diff --git a/docs/guide.md b/docs/guide.md index 60ae7d4f60..542d1e0f2b 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -175,6 +175,11 @@ resume/continue without current foreground proof never gets a fresh-session plac user-input requests are ephemeral, not persisted to the transcript: recorded `ask_user` choices can be read in Chat, but live answers still belong to the CLI's Terminal; the bridge never sends guessed selection keys or treats a question as an approval. +When the process-owned log records a foreground switch, Chat invalidates the previous +pagination cursors and tool-output links, including after switching back to the same session; +reload that session's current page for fresh links. If there is no readable lifecycle log, +the marker-and-report fallback can only detect switches it observes, not an unobserved +switch away and back. GJC chat requires GJC 0.18.9 or later for its broker-bound SDK session CLI, with `gjc` on the server PC's PATH and its SDK broker already running. An older GJC opens in Terminal. diff --git a/scripts/copilot-chat-browser-qa.ts b/scripts/copilot-chat-browser-qa.ts index 447d4899cd..2392d3d24b 100644 --- a/scripts/copilot-chat-browser-qa.ts +++ b/scripts/copilot-chat-browser-qa.ts @@ -76,7 +76,7 @@ console.log("fixture ready");`); const body = await (await fetch(endpoint)).json() as { source: string }; assert.equal(body.source, "copilot-transcript"); - browser = await chromium.launch({ executablePath: chromePath(), headless: true, args: ["--no-sandbox"] }); + browser = await chromium.launch({ executablePath: chromePath(), headless: true, chromiumSandbox: true }); const page = await browser.newPage({ viewport: { width: 1100, height: 800 }, locale: "en-US" }); const errors: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); diff --git a/server/conversation.ts b/server/conversation.ts index 8270c58a0c..cb62e14410 100644 --- a/server/conversation.ts +++ b/server/conversation.ts @@ -59,7 +59,7 @@ import { withinRetentionBudget } from "./retention-budget.ts"; import { forgetAllPiIndexes, forgetPiIndex, piAbandonedTurns, piBranchRecords, piBranchSegments } from "./pi-tree.ts"; import { trimOutput } from "./tool-output.ts"; import { copilotOutput, copilotRootRecord, parseCopilotTranscript } from "./copilot.ts"; -import { copilotExplicitResume, copilotLoggedSession, copilotTranscriptFile, copilotUnwrittenSession, openCopilotFile } from "./copilot-store.ts"; +import { copilotExplicitResume, copilotLoggedBinding, copilotLoggedSession, copilotTranscriptFile, copilotUnwrittenSession, openCopilotFile } from "./copilot-store.ts"; import { parseConversationMetadata } from "./conversation-metadata.ts"; import { forgetHermesTranscriptState, hermesConversationPage, hermesToolOutput, hermesTranscriptForPane } from "./hermes.ts"; @@ -566,6 +566,38 @@ type StreamSource = Exclude(); + +function copilotPrefix(paneId: string, identity: string): string { + const previous = copilotBindings.get(paneId); + if (previous?.identity === identity) return previous.prefix; + const prefix = `cp${createHash("sha256").update(`${PROCESS_VERSION}\0${paneId}\0${identity}\0${randomUUID()}`).digest("hex").slice(0, 16)}.`; + remember(copilotBindings, paneId, { identity, prefix }, PANE_READS_MAX); + return prefix; +} + +function copilotPage(page: ConversationPage, prefix: string): ConversationPage { + const unbind = (cursor?: string): string | undefined => { + if (cursor === undefined) return undefined; + if (!cursor.startsWith(prefix)) throw new HistoryChanged(); + return cursor.slice(prefix.length); + }; + return { before: unbind(page.before), since: unbind(page.since), from: unbind(page.from) }; +} + +function bindCopilotAnswer(answer: RecognizedConversation, prefix: string): RecognizedConversation { + return { + ...answer, + history_id: `${prefix}${answer.history_id}`, + cursor: answer.cursor === null ? null : `${prefix}${answer.cursor}`, + version: answerVersion(prefix, answer.version), + turns: answer.turns.map((turn) => ({ + ...turn, + parts: turn.parts.map((part) => part.kind === "tool" && part.output_ref + ? { ...part, output_ref: `${prefix}${part.output_ref}` } : part), + })), + }; +} export function answerVersion(key: string, signature: string): string { return createHash("sha256").update(`${PROCESS_VERSION}\0${key}\0${signature}`).digest("base64url").slice(0, 22); @@ -1282,6 +1314,7 @@ export function forgetTranscriptState(): void { transcriptRevisions.clear(); clearScans.clear(); paneReads.clear(); + copilotBindings.clear(); forgetAllCodexState(); forgetAllPiIndexes(); forgetGrokState(); @@ -1300,6 +1333,7 @@ export function forgetTranscriptState(): void { export function forgetPaneTranscriptState(paneId: string): void { const reads = paneReads.get(paneId); paneReads.delete(paneId); + copilotBindings.delete(paneId); launchedAgents.forget(paneId); forgetGjcPane(paneId); if (reads === undefined) return; @@ -1570,7 +1604,7 @@ async function ompTranscriptPath(paneId: string, cwd: string): Promise { } /** History requires Herdr's hook; a process-held unwritten session can show an empty chat. */ -async function copilotTranscriptPath(pane: HerdrPane, panes: readonly HerdrPane[]): Promise { +async function copilotTranscriptPath(pane: HerdrPane, panes: readonly HerdrPane[]): Promise<{ path: string; binding: string }> { const report = pane.agent_session; const claimedByPeer = async (session: string): Promise => { for (const peer of panes) { @@ -1600,20 +1634,22 @@ async function copilotTranscriptPath(pane: HerdrPane, panes: readonly HerdrPane[ } const environment = processEnviron(foreground.pid); const argv = foreground.argv ?? []; - const logged = copilotLoggedSession(environment, argv, foreground.pid); - if (logged !== null && !(await claimedByPeer(logged))) { - const path = copilotTranscriptFile(logged, environment, argv, foreground.pid); - if (path !== null && nodePath.basename(nodePath.dirname(path)) === logged) return path; + const logged = copilotLoggedBinding(environment, argv, foreground.pid); + if (logged !== null && !(await claimedByPeer(logged.sessionId))) { + const path = copilotTranscriptFile(logged.sessionId, environment, argv, foreground.pid); + if (path !== null && nodePath.basename(nodePath.dirname(path)) === logged.sessionId) + return { path, binding: `${foreground.pid}:${logged.generation}` }; } if (report != null) { if (await claimedByPeer(report.value)) throw new ConversationUnavailable("no_session_id"); if (logged === null) { const path = copilotTranscriptFile(report.value, environment, argv, foreground.pid); - if (path !== null && nodePath.basename(nodePath.dirname(path)) === report.value) return path; + if (path !== null && nodePath.basename(nodePath.dirname(path)) === report.value) + return { path, binding: `${foreground.pid}:held:${path}` }; } } const pending = copilotUnwrittenSession(environment, argv, foreground.pid); - if (pending !== null && (report == null || logged === pending.sessionId || report.value === pending.sessionId) + if (pending !== null && (report == null || logged?.sessionId === pending.sessionId || report.value === pending.sessionId) && !(await claimedByPeer(pending.sessionId))) { throw new ConversationNotStarted(pending.sessionId, "copilot-transcript", pending.path); } @@ -1622,7 +1658,7 @@ async function copilotTranscriptPath(pane: HerdrPane, panes: readonly HerdrPane[ /** Where a pane's conversation is: a transcript file, or a session in OpenCode's database. */ type ResolvedTranscript = - | { source: StreamSource; path: string; codexHome?: string; agy?: AgyStore } + | { source: StreamSource; path: string; codexHome?: string; agy?: AgyStore; binding?: string } | { source: "opencode-transcript"; path: string; session: string } | { source: "hermes-transcript"; path: string; sessionId: string }; @@ -1662,7 +1698,7 @@ async function resolveTranscript(pane: HerdrPane, cwd: string, codexHome?: strin // session headers name the one they run in const sessionCwd = typeof pane.foreground_cwd === "string" && pane.foreground_cwd.length > 0 ? pane.foreground_cwd : cwd; try { - if (agent === "copilot") return { source: "copilot-transcript", path: await copilotTranscriptPath(pane, panes ?? [pane]) }; + if (agent === "copilot") return { source: "copilot-transcript", ...await copilotTranscriptPath(pane, panes ?? [pane]) }; if (agent === "grok") { const found = await resolveGrokStore(pane, grokHome); if (!found) throw new ConversationUnavailable("no_session_path"); @@ -1913,7 +1949,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 answer = transcriptPage(resolved.source, resolved.path, page, resolved.codexHome ?? codexHome); + const prefix = resolved.source === "copilot-transcript" ? copilotPrefix(paneId, resolved.binding!) : null; + const answer = transcriptPage(resolved.source, resolved.path, prefix === null ? page : copilotPage(page, prefix), resolved.codexHome ?? codexHome); 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")) { @@ -1934,6 +1971,12 @@ export async function paneConversation(paneId: string, codexHome?: string, page: try { return { ...answer, activity: grokProjection(resolved.path, statSync(resolved.path)).activity }; } catch { throw new ConversationUnavailable("transcript_missing"); } } + if (prefix !== null) { + const pane = snapshot.panes.find((candidate) => candidate.pane_id === paneId)!; + const current = await copilotTranscriptPath(pane, snapshot.panes); + if (current.path !== resolved.path || current.binding !== resolved.binding) throw new HistoryChanged(); + return bindCopilotAnswer(answer, prefix); + } return answer; } @@ -2178,7 +2221,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 { - if (!TOOL_REF.test(ref)) return null; + if (!TOOL_REF.test(ref) && !/^cp[0-9a-f]{16}\.[A-Za-z0-9_:.-]{1,128}$/.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; @@ -2191,8 +2234,18 @@ export async function toolOutput(paneId: string, ref: string, codexHome?: string } rememberPaneRead(paneId, resolved.path); if (resolved.source === "hermes-transcript") return hermesToolOutput(resolved.sessionId, resolved.path, ref, TOOL_OUTPUT_MAX); - const output = transcriptToolOutput(resolved.source, resolved.path, ref, resolved.codexHome ?? codexHome); + let nativeRef = ref; + if (resolved.source === "copilot-transcript") { + const prefix = copilotPrefix(paneId, resolved.binding!); + if (!ref.startsWith(prefix)) return null; + nativeRef = ref.slice(prefix.length); + } + 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).catch(() => null); + if (current?.path !== resolved.path || current.binding !== resolved.binding) return null; + } return output; } diff --git a/server/copilot-store.ts b/server/copilot-store.ts index 0ecffc951c..3091f2a337 100644 --- a/server/copilot-store.ts +++ b/server/copilot-store.ts @@ -7,12 +7,13 @@ const MAX_DESCRIPTORS = 4096; const LOG_WINDOW_BYTES = 16 * 1024 * 1024; const LOG_NAME = /^process-\d+-(\d+)\.log$/; const FOREGROUND_EVENT = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+Z \[INFO\] (Registering|Unregistering) foreground session: ([\da-f-]{36})$/; -const foregroundCheckpoints = new Map(); +interface ForegroundBinding { sessionId: string; generation: string } +const foregroundCheckpoints = new Map(); /** The latest complete lifecycle event in the CLI's own open log, never the newest transcript. */ -function loggedForeground(pid: number, logDirectories: readonly string[]): string | null | undefined { +function loggedForeground(pid: number, logDirectories: readonly string[]): ForegroundBinding | null | undefined { const directory = opendirSync(`/proc/${pid}/fd`); - let found: string | null = null; + let found: ForegroundBinding | null = null; let count = 0; try { for (let descriptor = directory.readSync(); descriptor; descriptor = directory.readSync()) { @@ -39,23 +40,27 @@ function loggedForeground(pid: number, logDirectories: readonly string[]): strin if (read !== bytes.length || fstatSync(fd).size !== stat.size) return undefined; const text = bytes.subarray(0, read).toString("utf8"); if (text && !text.endsWith("\n")) return undefined; - const complete = text.slice(start === 0 ? 0 : text.indexOf("\n") + 1, text.lastIndexOf("\n") + 1); + const skip = start === 0 || same ? 0 : text.indexOf("\n") + 1; + const complete = text.slice(skip, text.lastIndexOf("\n") + 1); if (start > 0 && !complete && !same) return undefined; - let selected: string | null = same ? cached.session : null; + let selected: ForegroundBinding | null = same ? cached.binding : null; let observed = false; + let position = start + Buffer.byteLength(text.slice(0, skip)); for (const line of complete.split("\n")) { const match = FOREGROUND_EVENT.exec(line); - if (!match) continue; - observed = true; - if (!SESSION_ID.test(match[2]!)) return undefined; - if (match[1] === "Registering") selected = match[2]!; - else if (selected === match[2]) selected = null; - else return undefined; + if (match) { + observed = true; + if (!SESSION_ID.test(match[2]!)) return undefined; + if (match[1] === "Registering") selected = { sessionId: match[2]!, generation: `${pid}:${stat.dev}:${stat.ino}:${position}` }; + else if (selected?.sessionId === match[2]) selected = null; + else return undefined; + } + position += Buffer.byteLength(line) + 1; } if (!observed && !same) return undefined; if (selected === null) return undefined; if (foregroundCheckpoints.size >= 256 && !foregroundCheckpoints.has(pid)) foregroundCheckpoints.delete(foregroundCheckpoints.keys().next().value!); - foregroundCheckpoints.set(pid, { dev: stat.dev, ino: stat.ino, path: target, size: stat.size, session: selected }); + foregroundCheckpoints.set(pid, { dev: stat.dev, ino: stat.ino, path: target, size: stat.size, binding: selected }); found = selected; } finally { closeSync(fd); } } catch { return undefined; } @@ -131,7 +136,7 @@ export function heldSessions(pid: number, store: string): Set | null { return null; } -function processSessionFile(environment: readonly string[] | null, argv: readonly string[], pid: number): { sessionId: string; path: string; logged: boolean } | null { +function processSessionFile(environment: readonly string[] | null, argv: readonly string[], pid: number): { sessionId: string; path: string; logged: boolean; generation?: string } | null { if (environment === null || !Number.isSafeInteger(pid) || pid < 1) return null; const values = (key: string) => environment.filter((item) => item.startsWith(`${key}=`)).map((item) => item.slice(key.length + 1)); const home = values("HOME"); @@ -163,9 +168,9 @@ function processSessionFile(environment: readonly string[] | null, argv: readonl const foreground = loggedForeground(pid, logs); if (foreground === undefined) return null; if (foreground !== null) { - const directory = join(store, foreground); + const directory = join(store, foreground.sessionId); if (realpathSync(directory) !== directory) return null; - return { sessionId: foreground, path: join(directory, "events.jsonl"), logged: true }; + return { ...foreground, path: join(directory, "events.jsonl"), logged: true }; } const held = heldSessions(pid, store); if (held === null || held.size !== 1) return null; @@ -194,6 +199,12 @@ export function copilotLoggedSession(environment: readonly string[] | null, argv return found?.logged ? found.sessionId : null; } +/** The last process-owned registration identifies this selection, including a switch back to the same session. */ +export function copilotLoggedBinding(environment: readonly string[] | null, argv: readonly string[], pid: number): ForegroundBinding | null { + const found = processSessionFile(environment, argv, pid); + return found?.logged ? { sessionId: found.sessionId, generation: found.generation! } : null; +} + /** A process-held session with no transcript yet, never permission to read unreported history. */ export function copilotUnwrittenSession(environment: readonly string[] | null, argv: readonly string[], pid: number): { sessionId: string; path: string } | null { const held = processSessionFile(environment, argv, pid); diff --git a/server/copilot.contract.test.ts b/server/copilot.contract.test.ts index ec5dade19a..efea12cc1a 100644 --- a/server/copilot.contract.test.ts +++ b/server/copilot.contract.test.ts @@ -50,6 +50,9 @@ process.stdin.on("data", (chunk) => { appendFileSync(log, new Date().toISOString() + " [INFO] Registering foreground session: " + next + "\\n"); selected = next; } + else if (line === ":unregister" && lifecycle) { + appendFileSync(log, new Date().toISOString() + " [INFO] Unregistering foreground session: " + selected + "\\n"); + } else if (line === ":quit") process.exit(0); else appendFileSync(join(home, "input.txt"), line + "\\n"); } @@ -78,7 +81,7 @@ afterAll(async () => { it.skipIf(process.platform !== "linux")("binds only the reported UUID to the foreground process's home and rejects old cursors after a switch", async () => { const url = `http://127.0.0.1:${app.port}/api/pane/conversation?pane_id=${encodeURIComponent(pane)}`; - const read = async () => (await fetch(url)).json() as Promise<{ source: string; turns: { parts: { text?: string }[] }[]; cursor?: string; history_id?: string }>; + const read = async () => (await fetch(url)).json() as Promise<{ source: string; turns: { parts: { kind?: string; text?: string; output_ref?: string }[] }[]; cursor?: string; history_id?: string }>; const external = await read(); expect(external.source).toBe("copilot-transcript"); expect(external.turns).toEqual([]); @@ -106,12 +109,17 @@ it.skipIf(process.platform !== "linux")("binds only the reported UUID to the for expect(first.turns[0]?.parts[0]?.text).toBe("session A"); appendFileSync(file(id), event("assistant.message", { content: "Checking.", phase: "commentary", toolRequests: [{ toolCallId: "call-1", name: "shell", arguments: { command: "pwd" } }], model: "fixture-model" })); - const toolUrl = `http://127.0.0.1:${app.port}/api/pane/conversation/tool-output?${new URLSearchParams({ pane_id: pane, ref: "call-1" })}`; + const toolUrl = (ref: string) => `http://127.0.0.1:${app.port}/api/pane/conversation/tool-output?${new URLSearchParams({ pane_id: pane, ref })}`; expect((await read()).turns.at(-1)?.parts.at(-1)).toMatchObject({ kind: "tool", output: "" }); appendFileSync(file(id), event("tool.execution_start", { toolCallId: "call-1", toolName: "shell" }) + event("tool.execution_complete", { toolCallId: "call-1", success: false, result: { content: "error ".repeat(900) } })); - expect((await read()).turns.at(-1)?.parts.at(-1)).toMatchObject({ kind: "tool", error: true, output_ref: "call-1" }); - expect(await (await fetch(toolUrl)).text()).toBe("error ".repeat(900)); + const completed = (await read()).turns.at(-1)?.parts.at(-1); + 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((await fetch(toolUrl("call-1"))).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); expect((await read()).turns.at(-1)?.parts[0]?.text).toBe("appended"); @@ -158,7 +166,7 @@ it.skipIf(process.platform !== "linux")("binds only the reported UUID to the for expect((await sessionSnapshot()).panes.find((p) => p.pane_id === pane)?.agent_session?.value).toBe(id); expect((await read()).source).toBe("scrollback"); expect((await fetch(`${url}&from=${encodeURIComponent(first.cursor!)}`)).status).toBe(409); - expect((await fetch(toolUrl)).status).toBe(404); + expect((await fetch(toolUrl(completedRef))).status).toBe(404); await herdrRpc("pane.send_text", { pane_id: pane, text: ":quit\n" }); for (const deadline = Date.now() + 10_000;;) { if ((await sessionSnapshot()).panes.find((p) => p.pane_id === pane)?.agent !== "copilot") break; @@ -187,9 +195,9 @@ it.skipIf(process.platform !== "linux")("reads a resumed CLI without marker or r const forked = "12aa83d8-48bc-41f2-a498-7055c5c44a04"; for (const id of [resumed, forked]) { mkdirSync(join(home, "session-state", id), { recursive: true }); - writeFileSync(file(id), fixture(id, id) + event("assistant.message", { + writeFileSync(file(id), fixture(id, id) + Array.from({ length: 55 }, () => event("user.message", { content: id })).join("") + event("assistant.message", { toolRequests: [{ toolCallId: `call-${id}`, name: "shell", arguments: {} }], - }) + event("tool.execution_complete", { toolCallId: `call-${id}`, result: { content: `output-${id}` } })); + }) + event("tool.execution_complete", { toolCallId: `call-${id}`, result: { content: `output-${id} `.repeat(600) } })); } const created = await workspaceCreate({ cwd: root, label: "herdr-web-ui-test-copilot-lifecycle" }); const current = created.root_pane.pane_id; @@ -202,12 +210,20 @@ it.skipIf(process.platform !== "linux")("reads a resumed CLI without marker or r } const url = `http://127.0.0.1:${app.port}/api/pane/conversation?pane_id=${encodeURIComponent(current)}`; const read = async () => (await fetch(url)).json() as Promise<{ - source: string; turns: { parts: { text?: string }[] }[]; cursor?: string; history_id?: string; + source: string; turns: { parts: { text?: string; output_ref?: string }[] }[]; cursor?: string; history_id?: string; }>; const initial = await read(); expect(initial.source).toBe("copilot-transcript"); expect(initial.turns[0]?.parts[0]?.text).toBe(resumed); expect(initial.history_id).not.toStartWith("unwritten:"); + const oldCursor = initial.cursor!; + expect(oldCursor).toBeDefined(); + const oldRef = initial.turns.flatMap((turn) => turn.parts).find((part) => part.output_ref)?.output_ref; + expect(oldRef).toBeDefined(); + expect((await fetch(`${url}&from=${encodeURIComponent(oldCursor)}`)).status).toBe(200); + expect((await fetch(`${url}&before=${encodeURIComponent(oldCursor)}`)).status).toBe(200); + const outputUrl = (ref: string) => `http://127.0.0.1:${app.port}/api/pane/conversation/tool-output?${new URLSearchParams({ pane_id: current, ref })}`; + expect((await fetch(outputUrl(oldRef!))).status).toBe(200); const duplicate = await workspaceCreate({ cwd: root, label: "herdr-web-ui-test-copilot-log-peer" }); try { const peer = duplicate.root_pane.pane_id; @@ -239,10 +255,40 @@ it.skipIf(process.platform !== "linux")("reads a resumed CLI without marker or r if (Date.now() > deadline) throw new Error("switch-back was not selected"); await Bun.sleep(50); } + const returned = await read(); + expect(returned.history_id).not.toBe(initial.history_id); + expect((await fetch(`${url}&from=${encodeURIComponent(oldCursor)}`)).status).toBe(409); + expect((await fetch(`${url}&before=${encodeURIComponent(oldCursor)}`)).status).toBe(409); + expect((await fetch(outputUrl(oldRef!))).status).toBe(404); + const newRef = returned.turns.flatMap((turn) => turn.parts).find((part) => part.output_ref)?.output_ref; + expect(newRef).toBeDefined(); + expect(newRef).not.toBe(oldRef); + expect((await fetch(outputUrl(newRef!))).status).toBe(200); + expect((await fetch(`${url}&before=${encodeURIComponent(returned.cursor!)}`)).status).toBe(200); + await herdrRpc("pane.send_text", { pane_id: current, text: `:switch ${forked}\n:switch ${resumed}\n` }); + let unobserved = await read(); + for (const deadline = Date.now() + 5000; unobserved.history_id === returned.history_id;) { + if (Date.now() > deadline) throw new Error("unobserved switch-back did not advance binding"); + await Bun.sleep(50); + unobserved = await read(); + } + expect(unobserved.turns[0]?.parts[0]?.text).toBe(resumed); + expect((await fetch(`${url}&before=${encodeURIComponent(returned.cursor!)}`)).status).toBe(409); + expect((await fetch(outputUrl(newRef!))).status).toBe(404); + await herdrRpc("pane.send_text", { pane_id: current, text: ":unregister\n" }); + for (const deadline = Date.now() + 5000;;) { + if ((await read()).source === "scrollback") break; + if (Date.now() > deadline) throw new Error("unregistered foreground did not fail closed"); + await Bun.sleep(50); + } + expect((await fetch(`${url}&from=${encodeURIComponent(unobserved.cursor!)}`)).status).toBe(409); + const latestRef = unobserved.turns.flatMap((turn) => turn.parts).find((part) => part.output_ref)?.output_ref; + expect(latestRef).toBeDefined(); + expect((await fetch(outputUrl(latestRef!))).status).toBe(404); } finally { await workspaceClose(created.workspace.workspace_id); } -}); +}, 15_000); it.skipIf(process.platform !== "linux")("never mistakes an explicitly resumed launch without current evidence for a fresh empty chat", async () => { const resumed = "12aa83d8-48bc-41f2-a498-7055c5c44a05"; From f806bd7f727c02cc9cd98d37cccbb23ed4024906 Mon Sep 17 00:00:00 2001 From: Yu Xie Date: Thu, 22 Oct 2026 18:13:18 +0000 Subject: [PATCH 09/25] test(copilot): Confirm foreground re-registration after unregister Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 064cd7db-0ee7-4c99-ab55-74aad409d4b1 --- server/copilot.contract.test.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/server/copilot.contract.test.ts b/server/copilot.contract.test.ts index efea12cc1a..bca2c70ff4 100644 --- a/server/copilot.contract.test.ts +++ b/server/copilot.contract.test.ts @@ -53,6 +53,10 @@ process.stdin.on("data", (chunk) => { else if (line === ":unregister" && lifecycle) { appendFileSync(log, new Date().toISOString() + " [INFO] Unregistering foreground session: " + selected + "\\n"); } + else if (line.startsWith(":register ") && lifecycle) { + selected = line.slice(10); + appendFileSync(log, new Date().toISOString() + " [INFO] Registering foreground session: " + selected + "\\n"); + } else if (line === ":quit") process.exit(0); else appendFileSync(join(home, "input.txt"), line + "\\n"); } @@ -285,6 +289,12 @@ it.skipIf(process.platform !== "linux")("reads a resumed CLI without marker or r const latestRef = unobserved.turns.flatMap((turn) => turn.parts).find((part) => part.output_ref)?.output_ref; expect(latestRef).toBeDefined(); expect((await fetch(outputUrl(latestRef!))).status).toBe(404); + await herdrRpc("pane.send_text", { pane_id: current, text: `:register ${forked}\n` }); + for (const deadline = Date.now() + 5000;;) { + if ((await read()).turns[0]?.parts[0]?.text === forked) break; + if (Date.now() > deadline) throw new Error("new registration was not selected after unregistration"); + await Bun.sleep(50); + } } finally { await workspaceClose(created.workspace.workspace_id); } From e23a5e534e0e02e9afb3d287acfe1b4c8538ce93 Mon Sep 17 00:00:00 2001 From: Yu Xie Date: Thu, 22 Oct 2026 18:14:37 +0000 Subject: [PATCH 10/25] test(copilot): Allow isolated lifecycle fixture to finish Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 064cd7db-0ee7-4c99-ab55-74aad409d4b1 --- server/copilot.contract.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/copilot.contract.test.ts b/server/copilot.contract.test.ts index bca2c70ff4..fc2aaae320 100644 --- a/server/copilot.contract.test.ts +++ b/server/copilot.contract.test.ts @@ -192,7 +192,7 @@ it.skipIf(process.platform !== "linux")("binds only the reported UUID to the for expect((await read()).source).toBe("scrollback"); symlinkSync(file(id), file(other)); expect((await read()).source).toBe("scrollback"); -}); +}, 20_000); it.skipIf(process.platform !== "linux")("reads a resumed CLI without marker or report and follows foreground switches rather than stale reports", async () => { const resumed = "12aa83d8-48bc-41f2-a498-7055c5c44a03"; From fc3ca0049a3c54e09f49a63db34bc0fd85eaa51c Mon Sep 17 00:00:00 2001 From: Yu Xie Date: Thu, 22 Oct 2026 21:28:48 +0200 Subject: [PATCH 11/25] fix(chat): recover Copilot foreground log checkpoints safely Revalidate bounded log-content continuity before reusing a foreground binding, refresh selection generations after rewrites, and resynchronize from a bounded tail after oversized polling gaps. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1fad73aa-cbc5-42dd-916b-64e5198bc512 --- changelog.d/copilot-native-chat.md | 2 ++ server/copilot-store.ts | 41 +++++++++++++++++++---- server/copilot.test.ts | 52 +++++++++++++++++++++++++++++- 3 files changed, 87 insertions(+), 8 deletions(-) diff --git a/changelog.d/copilot-native-chat.md b/changelog.d/copilot-native-chat.md index fa8ca35396..4895866663 100644 --- a/changelog.d/copilot-native-chat.md +++ b/changelog.d/copilot-native-chat.md @@ -9,3 +9,5 @@ - Copilot pagination and tool-output links from a prior foreground selection no longer become valid again after switching back; an unregister without a new registration also stops serving the old conversation. +- Copilot foreground discovery revalidates log checkpoints after rewrites and recovers + from large polling gaps using bounded lifecycle-log reads. diff --git a/server/copilot-store.ts b/server/copilot-store.ts index 3091f2a337..5a2d0897c1 100644 --- a/server/copilot-store.ts +++ b/server/copilot-store.ts @@ -1,5 +1,6 @@ import { constants, closeSync, fstatSync, lstatSync, openSync, opendirSync, readlinkSync, readSync, realpathSync, statSync } from "node:fs"; import { basename, dirname, isAbsolute, join, relative, sep } from "node:path"; +import { createHash, randomUUID } from "node:crypto"; const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const HEADER_BYTES = 64 * 1024; @@ -8,7 +9,23 @@ const LOG_WINDOW_BYTES = 16 * 1024 * 1024; const LOG_NAME = /^process-\d+-(\d+)\.log$/; const FOREGROUND_EVENT = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+Z \[INFO\] (Registering|Unregistering) foreground session: ([\da-f-]{36})$/; interface ForegroundBinding { sessionId: string; generation: string } -const foregroundCheckpoints = new Map(); +interface ForegroundCheckpoint { + dev: number; ino: number; path: string; size: number; mtimeMs: number; ctimeMs: number; + boundary: string; epoch: string; binding: ForegroundBinding; +} +const foregroundCheckpoints = new Map(); + +/** Check consumed content at both ends before treating the next read as an append. */ +function logBoundary(fd: number, size: number): string | null { + const length = Math.min(size, HEADER_BYTES); + const bytes = Buffer.alloc(length); + const hash = createHash("sha256"); + for (const position of [0, Math.max(0, size - length)]) { + if (readSync(fd, bytes, 0, length, position) !== length) return null; + hash.update(bytes); + } + return hash.digest("hex"); +} /** The latest complete lifecycle event in the CLI's own open log, never the newest transcript. */ function loggedForeground(pid: number, logDirectories: readonly string[]): ForegroundBinding | null | undefined { @@ -32,12 +49,19 @@ function loggedForeground(pid: number, logDirectories: readonly string[]): Foreg const named = statSync(target); if (!stat.isFile() || stat.dev !== named.dev || stat.ino !== named.ino) return undefined; const cached = foregroundCheckpoints.get(pid); - const same = cached?.dev === stat.dev && cached.ino === stat.ino && cached.path === target && stat.size >= cached.size; + const same = cached?.dev === stat.dev && cached.ino === stat.ino && cached.path === target + && stat.size >= cached.size && stat.size - cached.size <= LOG_WINDOW_BYTES + && (stat.size !== cached.size || (stat.mtimeMs === cached.mtimeMs && stat.ctimeMs === cached.ctimeMs)) + && logBoundary(fd, cached.size) === cached.boundary; + if (!same) foregroundCheckpoints.delete(pid); + const epoch = same ? cached.epoch : randomUUID(); const start = same ? cached.size : Math.max(0, stat.size - LOG_WINDOW_BYTES); - if (stat.size - start > LOG_WINDOW_BYTES) return undefined; const bytes = Buffer.alloc(stat.size - start); const read = readSync(fd, bytes, 0, bytes.length, start); - if (read !== bytes.length || fstatSync(fd).size !== stat.size) return undefined; + const boundary = logBoundary(fd, stat.size); + const after = fstatSync(fd); + if (read !== bytes.length || boundary === null || after.size !== stat.size + || after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs) return undefined; const text = bytes.subarray(0, read).toString("utf8"); if (text && !text.endsWith("\n")) return undefined; const skip = start === 0 || same ? 0 : text.indexOf("\n") + 1; @@ -51,16 +75,19 @@ function loggedForeground(pid: number, logDirectories: readonly string[]): Foreg if (match) { observed = true; if (!SESSION_ID.test(match[2]!)) return undefined; - if (match[1] === "Registering") selected = { sessionId: match[2]!, generation: `${pid}:${stat.dev}:${stat.ino}:${position}` }; + if (match[1] === "Registering") selected = { sessionId: match[2]!, generation: `${pid}:${stat.dev}:${stat.ino}:${epoch}:${position}` }; else if (selected?.sessionId === match[2]) selected = null; - else return undefined; + else if (selected !== null) return undefined; } position += Buffer.byteLength(line) + 1; } if (!observed && !same) return undefined; if (selected === null) return undefined; if (foregroundCheckpoints.size >= 256 && !foregroundCheckpoints.has(pid)) foregroundCheckpoints.delete(foregroundCheckpoints.keys().next().value!); - foregroundCheckpoints.set(pid, { dev: stat.dev, ino: stat.ino, path: target, size: stat.size, binding: selected }); + foregroundCheckpoints.set(pid, { + dev: stat.dev, ino: stat.ino, path: target, size: stat.size, + mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs, boundary, epoch, binding: selected, + }); found = selected; } finally { closeSync(fd); } } catch { return undefined; } diff --git a/server/copilot.test.ts b/server/copilot.test.ts index b7366329cf..5f8cb61e65 100644 --- a/server/copilot.test.ts +++ b/server/copilot.test.ts @@ -3,7 +3,7 @@ import { appendFileSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileS import { tmpdir } from "node:os"; import { join } from "node:path"; import { parseCopilotTranscript } from "./copilot.ts"; -import { copilotLoggedSession, copilotTranscriptFile, copilotUnwrittenSession, heldSessions } from "./copilot-store.ts"; +import { copilotLoggedBinding, copilotLoggedSession, copilotTranscriptFile, copilotUnwrittenSession, heldSessions } from "./copilot-store.ts"; import { ConversationUnavailable, forgetTranscriptState, HistoryChanged, transcriptPage, transcriptToolOutput } from "./conversation.ts"; import { parseConversationMetadata } from "./conversation-metadata.ts"; @@ -313,6 +313,56 @@ it.skipIf(process.platform !== "linux")("uses only the process home and a canoni } }); +for (const scenario of ["rewrite", "gap"] as const) { + it.skipIf(process.platform !== "linux")(`recovers foreground discovery after a log ${scenario}`, async () => { + const root = mkdtempSync(join(tmpdir(), "copilot-log-continuity-")); + const other = "12aa83d8-48bc-41f2-a498-7055c5c44a02"; + for (const id of [session, other]) mkdirSync(join(root, "session-state", id), { recursive: true }); + mkdirSync(join(root, "logs")); + const child = Bun.spawn(["node", "-e", ` + const {openSync} = require("node:fs"); + openSync(process.argv[1] + "/logs/process-100-" + process.pid + ".log", "w"); + console.log("ready"); + process.stdin.resume(); + `, root], { stdin: "pipe", stdout: "pipe", stderr: "inherit" }); + const reader = child.stdout.getReader(); + try { + expect(new TextDecoder().decode((await reader.read()).value)).toContain("ready"); + const log = join(root, "logs", `process-100-${child.pid}.log`); + const register = (id: string) => `2026-10-22T00:00:00.000Z [INFO] Registering foreground session: ${id}\n`; + const options = [`COPILOT_HOME=${root}`]; + const binding = () => copilotLoggedBinding(options, ["copilot"], child.pid); + appendFileSync(log, register(session)); + const original = binding(); + expect(original?.sessionId).toBe(session); + if (scenario === "rewrite") { + writeFileSync(log, register(other)); + expect(binding()?.sessionId).toBe(other); + writeFileSync(log, register(session)); + expect(binding()?.sessionId).toBe(session); + expect(binding()?.generation).not.toBe(original?.generation); + writeFileSync(log, register(other) + "unrelated\n"); + expect(binding()?.sessionId).toBe(other); + const replaced = binding(); + appendFileSync(log, "ordinary append\n"); + expect(binding()?.generation).toBe(replaced?.generation); + } else { + appendFileSync(log, "unrelated\n".repeat(2 * 1024 * 1024)); + expect(binding()).toBeNull(); + appendFileSync(log, register(session).replace("Registering", "Unregistering") + register(other)); + expect(binding()?.sessionId).toBe(other); + appendFileSync(log, "unrelated\n".repeat(2 * 1024 * 1024) + register(session)); + expect(binding()?.sessionId).toBe(session); + expect(binding()?.generation).not.toBe(original?.generation); + } + } finally { + child.kill(); + await child.exited; + rmSync(root, { recursive: true, force: true }); + } + }); +} + it.skipIf(process.platform !== "linux")("follows process-owned foreground evidence through fork and switch-back with multiple holds", async () => { const root = mkdtempSync(join(tmpdir(), "copilot-foreground-")); const home = join(root, "home"); From 2bc5dab64b4f8979b6020d9df61479c4da1393f7 Mon Sep 17 00:00:00 2001 From: Yu Xie Date: Fri, 23 Oct 2026 10:32:45 +0000 Subject: [PATCH 12/25] fix(copilot): Run sandboxed browser fixture with Ubuntu Chrome Use the installed Ubuntu Chrome binary for the sandbox-required native Copilot browser fixture in CI; downloaded Playwright Chromium lacks the host AppArmor user-namespace policy. Retain the sandbox requirement and the existing local fallback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 064cd7db-0ee7-4c99-ab55-74aad409d4b1 --- docs/development.md | 5 ++++- scripts/copilot-chat-browser-qa.ts | 8 ++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/development.md b/docs/development.md index e2dc4591c3..70e8982e1b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -135,7 +135,10 @@ the test runner. A report is evidence, not an authorization to merge or publish. A local pass is not CI's: the PC has its own Node (CI pins 22), its own cores and its own system libraries. The browser lane uses the lockfile's Chromium, which it downloads into Playwright's -cache on first use. `HERDR_TEST_SHARDS=4` runs four integration files at a time, each with an +cache on first use. The native Copilot browser fixture requires Chromium sandboxing: on hosted +Ubuntu CI it uses the image's installed Chrome, whose AppArmor policy permits its user-namespace +sandbox, rather than the downloaded binary. It keeps sandboxing enabled and fails if unavailable. +`HERDR_TEST_SHARDS=4` runs four integration files at a time, each with an isolated herdr session. Local checks and hosted CI default to one; self-hosted CI uses four. The single commands still work on their own: diff --git a/scripts/copilot-chat-browser-qa.ts b/scripts/copilot-chat-browser-qa.ts index 2392d3d24b..ec0b446116 100644 --- a/scripts/copilot-chat-browser-qa.ts +++ b/scripts/copilot-chat-browser-qa.ts @@ -1,7 +1,7 @@ /** Owned native Copilot-shaped fixture -> Herdr -> HTTP -> built React chat. */ import "./test-herdr.ts"; import assert from "node:assert/strict"; -import { appendFileSync, chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { chromium } from "playwright-core"; @@ -76,7 +76,11 @@ console.log("fixture ready");`); const body = await (await fetch(endpoint)).json() as { source: string }; assert.equal(body.source, "copilot-transcript"); - browser = await chromium.launch({ executablePath: chromePath(), headless: true, chromiumSandbox: true }); + // Ubuntu's installed Chrome has an AppArmor policy allowing its user-namespace sandbox; + // the downloaded Playwright binary does not have that policy on hosted runners. + const installedChrome = "/opt/google/chrome/chrome"; + const executablePath = process.env.CI && existsSync(installedChrome) ? installedChrome : chromePath(); + browser = await chromium.launch({ executablePath, headless: true, chromiumSandbox: true }); const page = await browser.newPage({ viewport: { width: 1100, height: 800 }, locale: "en-US" }); const errors: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); From 260cfc0ae0a6db931c139859c3f3a29045b332a6 Mon Sep 17 00:00:00 2001 From: Yu Xie Date: Fri, 23 Oct 2026 22:48:57 +0200 Subject: [PATCH 13/25] Fix Copilot report fallback for logs without lifecycle events Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1fad73aa-cbc5-42dd-916b-64e5198bc512 --- changelog.d/copilot-native-chat.md | 2 ++ server/copilot-store.ts | 11 +++++-- server/copilot.contract.test.ts | 4 +-- server/copilot.test.ts | 52 ++++++++++++++++++++++++++++++ 4 files changed, 64 insertions(+), 5 deletions(-) diff --git a/changelog.d/copilot-native-chat.md b/changelog.d/copilot-native-chat.md index 4895866663..f473b9451a 100644 --- a/changelog.d/copilot-native-chat.md +++ b/changelog.d/copilot-native-chat.md @@ -11,3 +11,5 @@ also stops serving the old conversation. - Copilot foreground discovery revalidates log checkpoints after rewrites and recovers from large polling gaps using bounded lifecycle-log reads. +- Copilot sessions with a complete CLI log containing no lifecycle events still use + the verified Herdr report and unique process-held marker to read existing history. diff --git a/server/copilot-store.ts b/server/copilot-store.ts index 5a2d0897c1..bfab0dc70c 100644 --- a/server/copilot-store.ts +++ b/server/copilot-store.ts @@ -31,6 +31,7 @@ function logBoundary(fd: number, size: number): string | null { function loggedForeground(pid: number, logDirectories: readonly string[]): ForegroundBinding | null | undefined { const directory = opendirSync(`/proc/${pid}/fd`); let found: ForegroundBinding | null = null; + let logFound = false; let count = 0; try { for (let descriptor = directory.readSync(); descriptor; descriptor = directory.readSync()) { @@ -40,7 +41,8 @@ function loggedForeground(pid: number, logDirectories: readonly string[]): Foreg catch { return undefined; } if (target.endsWith(" (deleted)") && LOG_NAME.test(basename(target.slice(0, -10)))) return undefined; if (!logDirectories.some((path) => dirname(target) === path) || LOG_NAME.exec(basename(target))?.[1] !== String(pid)) continue; - if (found !== null) return undefined; + if (logFound) return undefined; + logFound = true; try { if (realpathSync(target) !== target) return undefined; const fd = openSync(`/proc/${pid}/fd/${descriptor.name}`, constants.O_RDONLY | constants.O_NONBLOCK); @@ -53,7 +55,6 @@ function loggedForeground(pid: number, logDirectories: readonly string[]): Foreg && stat.size >= cached.size && stat.size - cached.size <= LOG_WINDOW_BYTES && (stat.size !== cached.size || (stat.mtimeMs === cached.mtimeMs && stat.ctimeMs === cached.ctimeMs)) && logBoundary(fd, cached.size) === cached.boundary; - if (!same) foregroundCheckpoints.delete(pid); const epoch = same ? cached.epoch : randomUUID(); const start = same ? cached.size : Math.max(0, stat.size - LOG_WINDOW_BYTES); const bytes = Buffer.alloc(stat.size - start); @@ -81,7 +82,11 @@ function loggedForeground(pid: number, logDirectories: readonly string[]): Foreg } position += Buffer.byteLength(line) + 1; } - if (!observed && !same) return undefined; + if (!observed && !same) { + // A complete non-lifecycle log adds no identity; the report and held marker still must agree. + if (start === 0 && cached === undefined) continue; + return undefined; + } if (selected === null) return undefined; if (foregroundCheckpoints.size >= 256 && !foregroundCheckpoints.has(pid)) foregroundCheckpoints.delete(foregroundCheckpoints.keys().next().value!); foregroundCheckpoints.set(pid, { diff --git a/server/copilot.contract.test.ts b/server/copilot.contract.test.ts index fc2aaae320..c0eb25e7c8 100644 --- a/server/copilot.contract.test.ts +++ b/server/copilot.contract.test.ts @@ -32,9 +32,9 @@ const hold = (id) => { const dir = join(home, "session-state", id); mkdirSync(di const lifecycle = process.argv.includes("--lifecycle"); const log = join(home, "logs", \`process-100-\${process.pid}.log\`); let selected = process.argv[2]; +mkdirSync(join(home, "logs"), { recursive: true }); +openSync(log, "w"); if (lifecycle) { - mkdirSync(join(home, "logs"), { recursive: true }); - openSync(log, "w"); appendFileSync(log, new Date().toISOString() + " [INFO] Registering foreground session: " + selected + "\\n"); } else if (!process.argv.includes("--no-proof")) hold(selected); process.stdin.setEncoding("utf8"); diff --git a/server/copilot.test.ts b/server/copilot.test.ts index 5f8cb61e65..9eb5b466ad 100644 --- a/server/copilot.test.ts +++ b/server/copilot.test.ts @@ -313,6 +313,58 @@ it.skipIf(process.platform !== "linux")("uses only the process home and a canoni } }); +it.skipIf(process.platform !== "linux")("keeps report-and-marker fallback for a complete CLI log without lifecycle events", async () => { + const root = mkdtempSync(join(tmpdir(), "copilot-log-no-lifecycle-")); + const directory = join(root, "session-state", session); + mkdirSync(directory, { recursive: true }); + mkdirSync(join(root, "logs")); + const path = join(directory, "events.jsonl"); + writeFileSync(path, start + prompt("existing conversation")); + const child = Bun.spawn(["node", "-e", ` + const {openSync} = require("node:fs"); + const root = process.argv[1]; + openSync(root + "/session-state/${session}/inuse." + process.pid + ".hold", "w"); + openSync(root + "/logs/process-100-" + process.pid + ".log", "w"); + console.log("ready"); + process.stdin.on("data", () => { + openSync(root + "/logs/process-200-" + process.pid + ".log", "w"); + console.log("opened"); + }); + `, root], { stdin: "pipe", stdout: "pipe", stderr: "inherit" }); + const reader = child.stdout.getReader(); + try { + expect(new TextDecoder().decode((await reader.read()).value)).toContain("ready"); + const log = join(root, "logs", `process-100-${child.pid}.log`); + const environment = [`COPILOT_HOME=${root}`]; + const argv = ["copilot", `--resume=${session}`]; + for (const content of ["", "2026-10-23T00:00:00.000Z [INFO] CLI started\n"]) { + writeFileSync(log, content); + expect(copilotLoggedBinding(environment, argv, child.pid)).toBeNull(); + expect(copilotTranscriptFile(session, environment, argv, child.pid)).toBe(path); + expect(copilotTranscriptFile(null, environment, argv, child.pid)).toBeNull(); + expect(copilotTranscriptFile(session.slice(0, -2) + "02", environment, argv, child.pid)).toBeNull(); + } + writeFileSync(log, `2026-10-23T00:00:00.000Z [INFO] Unregistering foreground session: ${session}\n`); + expect(copilotTranscriptFile(session, environment, argv, child.pid)).toBeNull(); + writeFileSync(log, "x".repeat(16 * 1024 * 1024) + "\n"); + expect(copilotTranscriptFile(session, environment, argv, child.pid)).toBeNull(); + writeFileSync(log, "2026-10-23T00:00:00.000Z [INFO] CLI started"); + expect(copilotTranscriptFile(session, environment, argv, child.pid)).toBeNull(); + writeFileSync(log, `2026-10-23T00:00:00.000Z [INFO] Registering foreground session: ${session}\n`); + expect(copilotLoggedBinding(environment, argv, child.pid)?.sessionId).toBe(session); + writeFileSync(log, "2026-10-23T00:00:00.000Z [INFO] CLI restarted\n"); + expect(copilotTranscriptFile(session, environment, argv, child.pid)).toBeNull(); + expect(copilotTranscriptFile(session, environment, argv, child.pid)).toBeNull(); + child.stdin.write("extra\n"); + expect(new TextDecoder().decode((await reader.read()).value)).toContain("opened"); + expect(copilotTranscriptFile(session, environment, argv, child.pid)).toBeNull(); + } finally { + child.kill(); + await child.exited; + rmSync(root, { recursive: true, force: true }); + } +}); + for (const scenario of ["rewrite", "gap"] as const) { it.skipIf(process.platform !== "linux")(`recovers foreground discovery after a log ${scenario}`, async () => { const root = mkdtempSync(join(tmpdir(), "copilot-log-continuity-")); From 6e89b4b580de8916738119da9e58e55fb6ff9983 Mon Sep 17 00:00:00 2001 From: Yu Xie Date: Sat, 24 Oct 2026 20:54:10 +0200 Subject: [PATCH 14/25] Recover Copilot lifecycle checkpoints and deduplicate completion summaries Address maintainer recovery, descriptor churn and documentation findings. Preserve final-answer semantics when deduplicating completion summaries, as identified by independent review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1fad73aa-cbc5-42dd-916b-64e5198bc512 --- changelog.d/copilot-chat-startup.md | 3 -- changelog.d/copilot-native-chat.md | 16 ++------ docs/copilot-chat.md | 61 +++++++++++++++++++++++++++++ docs/guide.md | 16 +------- server/conversation.ts | 2 +- server/copilot-store.ts | 25 +++++------- server/copilot.test.ts | 48 +++++++++++++++++++++++ server/copilot.ts | 9 +++-- 8 files changed, 132 insertions(+), 48 deletions(-) delete mode 100644 changelog.d/copilot-chat-startup.md create mode 100644 docs/copilot-chat.md diff --git a/changelog.d/copilot-chat-startup.md b/changelog.d/copilot-chat-startup.md deleted file mode 100644 index a40c31ffd8..0000000000 --- a/changelog.d/copilot-chat-startup.md +++ /dev/null @@ -1,3 +0,0 @@ -### Fixed -- New Copilot sessions show an empty chat before the first message, including sessions started - in the terminal, instead of reporting that the conversation is unavailable. diff --git a/changelog.d/copilot-native-chat.md b/changelog.d/copilot-native-chat.md index f473b9451a..05cf99aba6 100644 --- a/changelog.d/copilot-native-chat.md +++ b/changelog.d/copilot-native-chat.md @@ -1,15 +1,7 @@ ### Added - Copilot CLI conversations can show native text, tool activity, recorded model, question choices and Autopilot completion summaries in chat on Linux when the foreground session is verified. - Answer interactive questions in Terminal. -### Fixed -- Resumed Copilot conversations show their history and follow in-process session switches when - the foreground process's lifecycle log proves the selection, even with older sessions open. - Unverifiable sessions fall back to Terminal instead of showing another session's history. -- Copilot pagination and tool-output links from a prior foreground selection no longer - become valid again after switching back; an unregister without a new registration - also stops serving the old conversation. -- Copilot foreground discovery revalidates log checkpoints after rewrites and recovers - from large polling gaps using bounded lifecycle-log reads. -- Copilot sessions with a complete CLI log containing no lifecycle events still use - the verified Herdr report and unique process-held marker to read existing history. + Verified new sessions can show an empty chat before the first message; resumed sessions + follow process-owned foreground lifecycle records with bounded, revalidated log reads. + A trusted Herdr report and unique held marker can identify history only without conflicting + lifecycle evidence. Unverifiable sessions use Terminal; interactive answers stay there too. diff --git a/docs/copilot-chat.md b/docs/copilot-chat.md new file mode 100644 index 0000000000..f4865c6d79 --- /dev/null +++ b/docs/copilot-chat.md @@ -0,0 +1,61 @@ +# Copilot chat + +On Linux, Chat reads Copilot CLI's native `events.jsonl`: user and assistant text, +tool activity, recorded model/effort, recorded `ask_user` choices and Autopilot completion +summaries. Reading Chat makes no model calls. Live questions and approvals stay in Terminal; +the bridge never sends guessed selection keys. macOS and Windows use Terminal. +This reader supports existing panes; it does not add Copilot to the launch picker. + +## Setup and session identity + +Run `herdr integration install copilot` in the effective Copilot home before starting a +pane to enable Herdr's session report. Ovis never changes the CLI configuration. +The reader uses the foreground process's `--config-dir`, then `COPILOT_HOME`, otherwise +`HOME/.copilot`; an explicit `--log-dir` selects its log directory. + +The foreground process's own open CLI log can identify its current session through +`Registering foreground session` and `Unregistering foreground session` records. +Copilot CLI 1.0.103 was observed emitting these records; this is not a guarantee for every +CLI version. A fresh registration can follow `/resume`, `/fork` and switch-back even +when older sessions remain open. File recency never selects a session. + +Without a selected lifecycle registration, reading history requires both a trusted Herdr +report and exactly one canonical session marker held by that foreground process. +This fallback is allowed when no process log has been observed, or when the complete initial +log contains no lifecycle events. It does **not** recover from an unregister, ambiguous +lifecycle records, incomplete log lines, lost/rewritten log continuity, a deleted or unreadable +log, or a catch-up gap beyond the 16 MiB scan window without a fresh registration. +These cases use Terminal until current foreground evidence is available; repeated polling +does not turn invalid evidence into permission to read old history. + +Existing panes with neither proof cannot be recovered by restarting the web server. +Resume the intended session in the CLI to establish fresh foreground evidence. + +## History and limits + +Before its first message, a genuinely new process can show an empty Chat when its +process-owned log or unique held marker identifies an unwritten session. +An explicit resume/continue without current foreground proof never gets a +fresh-session placeholder. + +A recorded foreground switch invalidates pagination cursors and tool-output links, +including after switching back to the same session. Reload the current page for fresh +links. The marker-and-report fallback cannot detect an unobserved switch away and back. + +The reader scans at most 16 MiB of lifecycle-log catch-up per lookup and checkpoints +complete reads, including unavailable selections. Ambiguity remains unavailable until a +fresh registration; it does not permanently block later registrations. +Transcript pages use the shared bounded pager, and ownership is checked again after reading. + +## Verification + +```sh +HERDR_TEST_MODE=unit bun test server/copilot.test.ts +bun run check run bun test --timeout 20000 server/copilot.contract.test.ts +bun run check run --build bun scripts/copilot-chat-browser-qa.ts +bun run check fast +``` + +The unit, HTTP contract and browser fixtures use fictional transcripts and disposable +processes. They establish reader behavior, not a live model run or compatibility with every +Copilot CLI release. Private transcripts and process logs are not published as fixtures. diff --git a/docs/guide.md b/docs/guide.md index 773059c445..99f94caeed 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -156,7 +156,7 @@ Every agent herdr runs shows up with its live status, terminal and alerts. The c | --- | --- | --- | | **Claude Code** | Native transcript, resolved through herdr | ✓ approvals, questions, plan and menu picks, and the `/model` list (picked for the session) | | **Codex** | Native rollout, with tool results and commentary/final phases | ✓ approvals and questions, including queued ones, and the `/model` lists (picked for the session) | -| **Copilot CLI** | Linux native `events.jsonl` when the pane's foreground process has a verifiable open CLI log recording the current foreground UUID, or Herdr reports the UUID and that process holds its unique session marker. The process's own `COPILOT_HOME` (otherwise `HOME/.copilot`) supplies the transcript. This follows `/resume`, `/fork` and switch-back without selecting by recency, even when older sessions remain open; Copilot CLI 1.0.103 was observed emitting foreground lifecycle events. Text, tools, recorded model/effort, `ask_user` question choices and Autopilot completion summaries are shown. Run `herdr integration install copilot` in the effective Copilot home before starting a pane to enable the Herdr session report; Ovis never changes your CLI setup. If the process-owned log is missing, truncated, rotated without continuity, unreadable, or lacks a verifiable foreground registration, only an unambiguous marker **and** trusted Herdr report allow reading history. Otherwise Chat falls back to Terminal rather than showing another session; macOS/Windows use Terminal. Existing panes with neither proof cannot be recovered by a server restart. This reads existing panes, not a Copilot option in **New workspace**. | — recorded choices are read-only; answer interactive questions and approvals in Terminal | +| **Copilot CLI** | Linux native transcript when the foreground session is verified. See [Copilot setup and limits](copilot-chat.md). | — recorded choices are read-only; answer questions and approvals in Terminal | | **omp** | Native session file | ✓ approvals, questions, and the model picker of `/model` and `/switch` (picked for the session: the chat sends `/model` as `/switch`, since omp's own `/model` opens its role hub) | | **omo** | Native session file, found through the pane's process tree | ✓ the `/model` list (picked for the session); other menus — use Terminal | | **gjc** | Native session file reported by the web UI's GJC extension, verified against the pane's process | — use Terminal | @@ -169,18 +169,6 @@ Every agent herdr runs shows up with its live status, terminal and alerts. The c | **Antigravity (agy)** | **Experimental; macOS untested.** Native ledger, selected by the running process’s held conversation lock. Linux native-tested; Windows uses terminal text. See [Agy behavior and limits](agy-chat.md). | — use Terminal | | **Anything else** | The terminal's text | — use Terminal | -Before its first message, a genuinely new Linux Copilot process can show an empty chat when its -process-owned log or unique held session marker identifies an unwritten session. An explicit -resume/continue without current foreground proof never gets a fresh-session placeholder. CLI -user-input requests are ephemeral, not persisted to the transcript: recorded `ask_user` choices -can be read in Chat, but live answers still belong to the CLI's Terminal; the bridge never sends -guessed selection keys or treats a question as an approval. -When the process-owned log records a foreground switch, Chat invalidates the previous -pagination cursors and tool-output links, including after switching back to the same session; -reload that session's current page for fresh links. If there is no readable lifecycle log, -the marker-and-report fallback can only detect switches it observes, not an unobserved -switch away and back. - GJC chat and GJC done alerts use a native extension rather than SDK session queries. From this checkout on each PC that runs GJC, explicitly install the extension: @@ -320,7 +308,7 @@ Serve the app over **HTTPS** to install it and receive push alerts. The simplest tailscale serve --bg --https=443 http://127.0.0.1:7317 ``` -The one-line installer runs this for you when Tailscale runs on the PC and does not serve the app yet, on the first free port of 443, 8443, 7317 and 17317, and prints the command that undoes it. To keep 443 for something else, name the port in `OVIS_TAILSCALE_HTTPS_PORT` (see [Configuration](#configuration)), or set it for the installer's run alone: `curl -fsSL https://ovis.sh/install.sh | OVIS_TAILSCALE_HTTPS_PORT=9443 sh`. On Linux, `tailscale serve` needs root or `sudo tailscale set --operator="$(id -un)"` once; the installer says so when Tailscale refuses. +The one-line installer runs this for you when Tailscale runs on the PC and does not serve the app yet, on the first free port of 443, 8443, 7317 and 17317, and prints the command that undoes it. To keep 443 for something else, name the port in `OVIS_TAILSCALE_HTTPS_PORT` (see [Configuration](#configuration)), or set it for the installer's run alone: `curl -fsSL https://ovis.sh/install.sh | OVIS_TAILSCALE_HTTPS_PORT=9443 sh`. On Linux, `tailscale serve` needs root or `sudo tailscale set --operator=$USER` once; the installer says so when Tailscale refuses. An address that already serves the app is kept as it is. To move it to another port, turn the old one off (`tailscale serve --https=443 off`), then run the installer again. The installed app on a phone stays on the address it was installed from, so install it again from the new one. diff --git a/server/conversation.ts b/server/conversation.ts index 7b43c6a7f6..fdeb66702f 100644 --- a/server/conversation.ts +++ b/server/conversation.ts @@ -1603,7 +1603,7 @@ async function ompTranscriptPath(paneId: string, cwd: string): Promise { return inStore; } -/** History requires Herdr's hook; a process-held unwritten session can show an empty chat. */ +/** A process-owned lifecycle registration, or a trusted report plus a unique held marker. */ async function copilotTranscriptPath(pane: HerdrPane, panes: readonly HerdrPane[]): Promise<{ path: string; binding: string }> { const report = pane.agent_session; const claimedByPeer = async (session: string): Promise => { diff --git a/server/copilot-store.ts b/server/copilot-store.ts index bfab0dc70c..fee3e585f3 100644 --- a/server/copilot-store.ts +++ b/server/copilot-store.ts @@ -11,7 +11,7 @@ const FOREGROUND_EVENT = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+Z \[INFO\] (Regist interface ForegroundBinding { sessionId: string; generation: string } interface ForegroundCheckpoint { dev: number; ino: number; path: string; size: number; mtimeMs: number; ctimeMs: number; - boundary: string; epoch: string; binding: ForegroundBinding; + boundary: string; epoch: string; binding: ForegroundBinding | null | undefined; } const foregroundCheckpoints = new Map(); @@ -30,7 +30,7 @@ function logBoundary(fd: number, size: number): string | null { /** The latest complete lifecycle event in the CLI's own open log, never the newest transcript. */ function loggedForeground(pid: number, logDirectories: readonly string[]): ForegroundBinding | null | undefined { const directory = opendirSync(`/proc/${pid}/fd`); - let found: ForegroundBinding | null = null; + let found: ForegroundBinding | null | undefined = null; let logFound = false; let count = 0; try { @@ -38,7 +38,10 @@ function loggedForeground(pid: number, logDirectories: readonly string[]): Foreg if (++count > MAX_DESCRIPTORS) return undefined; let target: string; try { target = readlinkSync(`/proc/${pid}/fd/${descriptor.name}`); } - catch { return undefined; } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + return undefined; + } if (target.endsWith(" (deleted)") && LOG_NAME.test(basename(target.slice(0, -10)))) return undefined; if (!logDirectories.some((path) => dirname(target) === path) || LOG_NAME.exec(basename(target))?.[1] !== String(pid)) continue; if (logFound) return undefined; @@ -68,26 +71,18 @@ function loggedForeground(pid: number, logDirectories: readonly string[]): Foreg const skip = start === 0 || same ? 0 : text.indexOf("\n") + 1; const complete = text.slice(skip, text.lastIndexOf("\n") + 1); if (start > 0 && !complete && !same) return undefined; - let selected: ForegroundBinding | null = same ? cached.binding : null; - let observed = false; + // Only a complete initial log without lifecycle evidence permits marker/report fallback. + let selected = same ? cached.binding : start === 0 && cached === undefined ? null : undefined; let position = start + Buffer.byteLength(text.slice(0, skip)); for (const line of complete.split("\n")) { const match = FOREGROUND_EVENT.exec(line); if (match) { - observed = true; if (!SESSION_ID.test(match[2]!)) return undefined; if (match[1] === "Registering") selected = { sessionId: match[2]!, generation: `${pid}:${stat.dev}:${stat.ino}:${epoch}:${position}` }; - else if (selected?.sessionId === match[2]) selected = null; - else if (selected !== null) return undefined; + else selected = undefined; } position += Buffer.byteLength(line) + 1; } - if (!observed && !same) { - // A complete non-lifecycle log adds no identity; the report and held marker still must agree. - if (start === 0 && cached === undefined) continue; - return undefined; - } - if (selected === null) return undefined; if (foregroundCheckpoints.size >= 256 && !foregroundCheckpoints.has(pid)) foregroundCheckpoints.delete(foregroundCheckpoints.keys().next().value!); foregroundCheckpoints.set(pid, { dev: stat.dev, ino: stat.ino, path: target, size: stat.size, @@ -98,7 +93,7 @@ function loggedForeground(pid: number, logDirectories: readonly string[]): Foreg } catch { return undefined; } } } finally { directory.closeSync(); } - return found; + return !logFound && foregroundCheckpoints.has(pid) ? undefined : found; } /** Pin one regular canonical file; callers must close the descriptor after reading. */ diff --git a/server/copilot.test.ts b/server/copilot.test.ts index 9eb5b466ad..1d2b315b28 100644 --- a/server/copilot.test.ts +++ b/server/copilot.test.ts @@ -103,6 +103,21 @@ it.each(["commentary", "final_answer", undefined])("tracks the latest assistant expect(parseCopilotTranscript(transcript + missingTimestamp).at(-1)?.end_ts).toBe(stamp); }); +it("does not repeat a completion summary already present in the last unphased reply", () => { + for (const phase of [undefined, "commentary"]) { + for (const ending of ["", prompt("next question")]) { + const turns = parseCopilotTranscript(start + prompt("finish") + + line("assistant.message", { content: "All done.", phase }) + + line("session.task_complete", { summary: "All done." }) + ending); + expect(turns[1]?.parts).toEqual([{ kind: "text", text: "All done.", phase: "final_answer" }]); + } + } + const distinct = parseCopilotTranscript(start + prompt("finish") + + line("assistant.message", { content: "Working." }) + + line("session.task_complete", { summary: "All done." })); + expect(distinct[1]?.parts).toHaveLength(2); +}); + it("advances activity time past intermediate tool rounds and the final answer without changing turn boundaries", () => { const at = (type: string, data: object, second: number) => JSON.stringify({ type, data, timestamp: `2026-10-21T00:00:0${second}Z` }) + "\n"; @@ -415,6 +430,39 @@ for (const scenario of ["rewrite", "gap"] as const) { }); } +it.skipIf(process.platform !== "linux")("recovers from an ambiguous unregister only after a fresh registration", async () => { + const root = mkdtempSync(join(tmpdir(), "copilot-log-unregister-")); + const other = "12aa83d8-48bc-41f2-a498-7055c5c44a02"; + for (const id of [session, other]) mkdirSync(join(root, "session-state", id), { recursive: true }); + mkdirSync(join(root, "logs")); + const child = Bun.spawn(["node", "-e", ` + require("node:fs").openSync(process.argv[1] + "/logs/process-100-" + process.pid + ".log", "w"); + console.log("ready"); + process.stdin.resume(); + `, root], { stdin: "pipe", stdout: "pipe", stderr: "inherit" }); + try { + expect(new TextDecoder().decode((await child.stdout.getReader().read()).value)).toContain("ready"); + const log = join(root, "logs", `process-100-${child.pid}.log`); + const event = (action: string, id: string) => `2026-10-24T00:00:00.000Z [INFO] ${action} foreground session: ${id}\n`; + const binding = () => copilotLoggedBinding([`COPILOT_HOME=${root}`], ["copilot"], child.pid); + appendFileSync(log, event("Registering", session) + event("Registering", other) + event("Unregistering", session)); + expect(binding()).toBeNull(); + expect(binding()).toBeNull(); + appendFileSync(log, "ordinary append\n"); + expect(binding()).toBeNull(); + appendFileSync(log, event("Registering", session)); + expect(binding()?.sessionId).toBe(session); + appendFileSync(log, event("Unregistering", session)); + expect(binding()).toBeNull(); + appendFileSync(log, event("Registering", other)); + expect(binding()?.sessionId).toBe(other); + } finally { + child.kill(); + await child.exited; + rmSync(root, { recursive: true, force: true }); + } +}); + it.skipIf(process.platform !== "linux")("follows process-owned foreground evidence through fork and switch-back with multiple holds", async () => { const root = mkdtempSync(join(tmpdir(), "copilot-foreground-")); const home = join(root, "home"); diff --git a/server/copilot.ts b/server/copilot.ts index 40ca41d68d..a790af5398 100644 --- a/server/copilot.ts +++ b/server/copilot.ts @@ -38,9 +38,12 @@ export function parseCopilotTranscript(jsonl: string, maxTurns = MAX_TURNS): Con let completion: string | null = null; const flushCompletion = (): void => { const last = turns.at(-1); - if (completion !== null && last?.role === "assistant" - && !last.parts.some((part) => part.kind === "text" && part.phase === "final_answer")) { - last.parts.push({ kind: "text", text: completion, phase: "final_answer" }); + const lastText = last?.parts.findLast((part) => part.kind === "text"); + if (completion !== null && last?.role === "assistant") { + if (lastText?.kind === "text" && lastText.text === completion) lastText.phase = "final_answer"; + else if (!last.parts.some((part) => part.kind === "text" && part.phase === "final_answer")) { + last.parts.push({ kind: "text", text: completion, phase: "final_answer" }); + } } completion = null; }; From 1f200f56fd8317660cd00cf6f2b8ea03b298e88c Mon Sep 17 00:00:00 2001 From: Yu Xie Date: Sat, 24 Oct 2026 21:21:34 +0200 Subject: [PATCH 15/25] Retain invalid Copilot log evidence for the verified process lifetime Fail closed at the live-process tracking bound instead of evicting denial state. Preserve consumed checkpoints while waiting for incomplete trailing lines to finish. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1fad73aa-cbc5-42dd-916b-64e5198bc512 --- docs/copilot-chat.md | 6 ++++++ server/copilot-store.ts | 39 +++++++++++++++++++++++++++++---------- server/copilot.test.ts | 4 ++++ 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/docs/copilot-chat.md b/docs/copilot-chat.md index f4865c6d79..77be371286 100644 --- a/docs/copilot-chat.md +++ b/docs/copilot-chat.md @@ -45,6 +45,12 @@ links. The marker-and-report fallback cannot detect an unobserved switch away an The reader scans at most 16 MiB of lifecycle-log catch-up per lookup and checkpoints complete reads, including unavailable selections. Ambiguity remains unavailable until a fresh registration; it does not permanently block later registrations. +An incomplete trailing line temporarily blocks Chat. If log continuity remains intact, +the reader processes that line once it is complete; it never serves the older binding +while the line could contain an unfinished foreground switch. +Observation state is bounded to 256 live process identities. Exited or replaced processes +release slots; at capacity, untracked processes use Terminal rather than forgetting a live +process's invalid evidence. Transcript pages use the shared bounded pager, and ownership is checked again after reading. ## Verification diff --git a/server/copilot-store.ts b/server/copilot-store.ts index 5e674ef785..420d88eadb 100644 --- a/server/copilot-store.ts +++ b/server/copilot-store.ts @@ -1,6 +1,7 @@ import { constants, closeSync, fstatSync, lstatSync, openSync, opendirSync, readlinkSync, readSync, realpathSync, statSync } from "node:fs"; import { basename, dirname, isAbsolute, join, relative, sep } from "node:path"; import { createHash, randomUUID } from "node:crypto"; +import { processAlive, processStartedAt } from "./process-start.ts"; const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const HEADER_BYTES = 64 * 1024; @@ -14,7 +15,7 @@ interface ForegroundCheckpoint { boundary: string; epoch: string; binding: ForegroundBinding | null | undefined; } const foregroundCheckpoints = new Map(); -const observedForegroundLogs = new Set(); +const foregroundLogProcesses = new Map(); /** Check consumed content at both ends before treating the next read as an append. */ function logBoundary(fd: number, size: number): string | null { @@ -30,14 +31,32 @@ function logBoundary(fd: number, size: number): string | null { /** The latest complete lifecycle event in the CLI's own open log, never the newest transcript. */ function loggedForeground(pid: number, logDirectories: readonly string[]): ForegroundBinding | null | undefined { - const observedBefore = observedForegroundLogs.has(pid); - const observe = (): void => { - if (observedForegroundLogs.size >= 256 && !observedForegroundLogs.has(pid)) { - const oldest = observedForegroundLogs.values().next().value!; - observedForegroundLogs.delete(oldest); - foregroundCheckpoints.delete(oldest); + const started = processStartedAt(pid); + if (started === null) return undefined; + let owner = foregroundLogProcesses.get(pid); + if (owner?.started !== started) { + foregroundLogProcesses.delete(pid); + foregroundCheckpoints.delete(pid); + owner = undefined; + } + if (owner === undefined) { + if (foregroundLogProcesses.size >= 256) { + for (const [previousPid, previous] of foregroundLogProcesses) { + const current = processStartedAt(previousPid); + if ((current !== null && current !== previous.started) || !processAlive(previousPid)) { + foregroundLogProcesses.delete(previousPid); + foregroundCheckpoints.delete(previousPid); + } + } } - observedForegroundLogs.add(pid); + // Dropping live denial state would make stale report/marker fallback eligible again. + if (foregroundLogProcesses.size >= 256) return undefined; + owner = { started, observed: false }; + foregroundLogProcesses.set(pid, owner); + } + const observedBefore = owner.observed; + const observe = (): void => { + owner.observed = true; }; const unavailable = (): undefined => { const checkpoint = foregroundCheckpoints.get(pid); @@ -86,7 +105,7 @@ function loggedForeground(pid: number, logDirectories: readonly string[]): Foreg if (read !== bytes.length || boundary === null || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs) return unavailable(); const text = bytes.subarray(0, read).toString("utf8"); - if (text && !text.endsWith("\n")) return unavailable(); + if (text && !text.endsWith("\n")) return undefined; const skip = start === 0 || same ? 0 : text.indexOf("\n") + 1; const complete = text.slice(skip, text.lastIndexOf("\n") + 1); if (start > 0 && !complete && !same) return unavailable(); @@ -111,7 +130,7 @@ function loggedForeground(pid: number, logDirectories: readonly string[]): Foreg } catch { return unavailable(); } } } finally { directory.closeSync(); } - return !logFound && observedForegroundLogs.has(pid) ? unavailable() : found; + return !logFound && owner.observed ? unavailable() : found; } /** Pin one regular canonical file; callers must close the descriptor after reading. */ diff --git a/server/copilot.test.ts b/server/copilot.test.ts index 37883c306d..1f9ca84b64 100644 --- a/server/copilot.test.ts +++ b/server/copilot.test.ts @@ -561,6 +561,10 @@ it.skipIf(process.platform !== "linux")("follows process-owned foreground eviden expect(copilotTranscriptFile(other, options, args, child.pid)).toBe(join(home, "session-state", other, "events.jsonl")); await command("back"); expect(copilotLoggedSession(options, args, child.pid)).toBe(session); + appendFileSync(log, "ordinary partial line"); + expect(copilotLoggedSession(options, args, child.pid)).toBeNull(); + appendFileSync(log, "\n"); + expect(copilotLoggedSession(options, args, child.pid)).toBe(session); await command("incomplete"); expect(copilotLoggedSession(options, args, child.pid)).toBeNull(); expect(copilotTranscriptFile(session, options, args, child.pid)).toBeNull(); From dab6d400399a7355a0e4f28a68519eb90ca67642 Mon Sep 17 00:00:00 2001 From: Yu Xie Date: Sat, 24 Oct 2026 21:35:49 +0200 Subject: [PATCH 16/25] Keep native Copilot reader independent of launcher bookkeeping Remove the unreachable launch placeholder and its extra foreground-process lookup. Retain verified process-owned unwritten sessions and conservative resumed-session fallback. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1fad73aa-cbc5-42dd-916b-64e5198bc512 --- server/conversation.ts | 7 +------ server/copilot.contract.test.ts | 11 ----------- server/launched-agents.test.ts | 11 ----------- server/launched-agents.ts | 1 - 4 files changed, 1 insertion(+), 29 deletions(-) diff --git a/server/conversation.ts b/server/conversation.ts index c56f7ab86b..37082d2414 100644 --- a/server/conversation.ts +++ b/server/conversation.ts @@ -1235,12 +1235,7 @@ export async function paneConversation(paneId: string, codexHome?: string, page: const pane = snapshot.panes.find((candidate) => candidate.pane_id === paneId); if (pane === undefined) { launchedAgents.forget(paneId); throw error; } if (pane.agent === "copilot" && (page.before !== undefined || page.since !== undefined || page.from !== undefined)) throw new HistoryChanged(); - if (pane.agent === "copilot" && error.message === "copilot_resume_unknown") throw error; - if (pane.agent === "copilot") { - const info = await paneProcessInfo(paneId).catch(() => null); - const processes = agentProcesses(info?.foreground_processes ?? [], "copilot"); - if (processes.length !== 1 || copilotExplicitResume(processes[0]?.argv ?? [])) throw error; - } + if (pane.agent === "copilot") throw error; // an agent this server started writes no session before its first message (launched-agents.ts) const launch = launchedAgents.notBegun(paneId, pane.agent_status); if (launch === null) { diff --git a/server/copilot.contract.test.ts b/server/copilot.contract.test.ts index c0eb25e7c8..fca21ae8ca 100644 --- a/server/copilot.contract.test.ts +++ b/server/copilot.contract.test.ts @@ -5,7 +5,6 @@ import { join } from "node:path"; import { createServer } from "./index.ts"; import { herdrRpc, sessionSnapshot, workspaceClose, workspaceCreate } from "./herdr/client.ts"; import { forgetTranscriptState } from "./conversation.ts"; -import { launchedAgents } from "./launched-agents.ts"; const id = "12aa83d8-48bc-41f2-a498-7055c5c44a01"; const other = "12aa83d8-48bc-41f2-a498-7055c5c44a02"; @@ -77,7 +76,6 @@ console.log("fixture ready");`); afterAll(async () => { app?.stop(); - if (pane) launchedAgents.forget(pane); if (workspace) await workspaceClose(workspace); forgetTranscriptState(); rmSync(root, { recursive: true, force: true }); @@ -99,16 +97,9 @@ it.skipIf(process.platform !== "linux")("binds only the reported UUID to the for }; await report(id, 1, "untrusted"); expect((await read()).source).toBe("scrollback"); - launchedAgents.launched(pane, "copilot"); - const pending = await read(); - expect(pending.source).toBe("copilot-transcript"); - expect(pending.turns).toEqual([]); - expect(pending.history_id).toStartWith("unwritten:launch:"); - expect((await read()).history_id).toBe(pending.history_id); await report(id, 2); const first = await read(); expect(first.source).toBe("copilot-transcript"); - expect(first.history_id).not.toBe(pending.history_id); expect(first.history_id).not.toBe(external.history_id); expect(first.turns[0]?.parts[0]?.text).toBe("session A"); appendFileSync(file(id), event("assistant.message", { content: "Checking.", phase: "commentary", @@ -313,12 +304,10 @@ it.skipIf(process.platform !== "linux")("never mistakes an explicitly resumed la if (Date.now() > deadline) throw new Error("owned no-proof fixture not detected"); await Bun.sleep(50); } - launchedAgents.launched(current, "copilot"); const url = `http://127.0.0.1:${app.port}/api/pane/conversation?pane_id=${encodeURIComponent(current)}`; expect((await (await fetch(url)).json()).source).toBe("scrollback"); expect((await fetch(`${url}&from=stale`)).status).toBe(409); } finally { - launchedAgents.forget(current); await workspaceClose(created.workspace.workspace_id); } }); diff --git a/server/launched-agents.test.ts b/server/launched-agents.test.ts index c6f25ef885..1914b87c3f 100644 --- a/server/launched-agents.test.ts +++ b/server/launched-agents.test.ts @@ -29,17 +29,6 @@ describe("LaunchedAgents", () => { expect(launched.notBegun("w1:p1", "done", 60_000)).toBeNull(); }); - it("keeps a new Copilot chat empty until its first turn, without claiming an untracked pane", () => { - const launched = new LaunchedAgents(); - launched.launched("w1:p1", "copilot", 0); - const first = launched.notBegun("w1:p1", "idle", 5_000); - expect(first).toEqual({ source: "copilot-transcript", historyId: "unwritten:launch:w1:p1:1" }); - expect(launched.notBegun("w1:p1", "idle", 6_000)).toEqual(first); - expect(launched.notBegun("w1:p2", "idle", 6_000)).toBeNull(); - expect(launched.notBegun("w1:p1", "working", 7_000)).toBeNull(); - expect(launched.notBegun("w1:p1", "done", 8_000)).toBeNull(); - }); - it("takes an agent at work long after its launch for one on its first turn", () => { const launched = new LaunchedAgents(); launched.launched("w1:p1", "opencode", 0); diff --git a/server/launched-agents.ts b/server/launched-agents.ts index 0683780fa8..21792b77ef 100644 --- a/server/launched-agents.ts +++ b/server/launched-agents.ts @@ -14,7 +14,6 @@ import type { RecognizedConversation } from "./conversation.ts"; */ const SOURCES: Record = { agy: "agy-transcript", claude: "claude-transcript", codex: "codex-transcript", devin: "devin-transcript", - copilot: "copilot-transcript", gjc: "gjc-transcript", grok: "grok-transcript", hermes: "hermes-transcript", omo: "omo-transcript", omp: "omp-transcript", opencode: "opencode-transcript", pi: "pi-transcript", }; From 6573986d062c91725d88a762bba6a182e6665894 Mon Sep 17 00:00:00 2001 From: Yu Xie Date: Sun, 25 Oct 2026 11:36:39 +0100 Subject: [PATCH 17/25] fix(chat): retry unavailable conversations without changing lenses Use the existing polling cadence while native history is unavailable; restore the push backstop only after a verified native read. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1fad73aa-cbc5-42dd-916b-64e5198bc512 --- DESIGN.md | 5 +++++ scripts/copilot-chat-browser-qa.ts | 15 ++++++++++++++- src/components/ChatView.tsx | 4 +++- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 3902670cb2..74238e02c6 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1749,6 +1749,11 @@ Alert rows of the table above from the `--droplet-*` roles in `src/styles.css`. that has not written its transcript yet (a new Claude Code or Codex pane before its first message), so those are not greeted; an omo session not yet written answers an empty conversation and is. + While terminal output stands in for an unavailable native conversation, Chat retries on the + existing 2 s polling cadence, not the 10 s native-push backstop. A verified native read restores + the ordinary cadence without a lens switch; rejected history stays hidden meanwhile. This + follows T3 Code's separation of [transport health and data freshness](https://github.com/pingdotgg/t3code/blob/main/docs/internals/connection-runtime.md): + an open socket alone does not prove the conversation is current. No code or new controls are borrowed. ### Work block (`.work-block`, `.work-row`) - One per assistant turn: a `▸ Worked for 7s · 1 edit · 2 commands` header (duration = next turn's diff --git a/scripts/copilot-chat-browser-qa.ts b/scripts/copilot-chat-browser-qa.ts index ec0b446116..93654cd332 100644 --- a/scripts/copilot-chat-browser-qa.ts +++ b/scripts/copilot-chat-browser-qa.ts @@ -142,8 +142,21 @@ console.log("fixture ready");`); if (Date.now() > deadline) throw new Error("composer text never reached owned Copilot fixture PTY"); await Bun.sleep(50); } + let unavailable = true; + const conversationRoute = /\/api\/pane\/conversation\?/; + await page.route(conversationRoute, (route) => unavailable + ? route.fulfill({ json: { source: "scrollback", turns: [] } }) + : route.continue()); + appendFileSync(transcript, event("user.message", { content: "Recover after a transient lookup" }) + + event("assistant.message", { content: "Conversation recovered without switching lenses.", phase: "final_answer" })); + await page.locator(".chat-terminal-fallback summary").waitFor(); + assert.equal(await log.getByText("Native Copilot answer.", { exact: true }).count(), 0, "unverified history is hidden"); + unavailable = false; + await log.getByText("Conversation recovered without switching lenses.", { exact: true }).waitFor({ timeout: 4000 }); + await page.locator(".chat-terminal-fallback").waitFor({ state: "detached" }); + await page.unroute(conversationRoute); assert.deepEqual(errors, []); - console.log("PASS Copilot native HTTP/React, tool detail, append, desktop/mobile and PTY send"); + console.log("PASS Copilot native HTTP/React, tool detail, append, desktop/mobile, PTY send and transient recovery"); } finally { const failures: unknown[] = []; try { await browser?.close(); } catch (error) { failures.push(error); } diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index a68eaf70ed..74d40458a2 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -1295,7 +1295,9 @@ export const ChatView = memo(function ChatView({ paneId, presentationKey, finish if (seen.pane === paneId && seen.key !== invalidationKey && visible) conversationRefresh.invalidate(); }, [paneId, invalidationKey, visible, conversationRefresh]); - useEffect(() => { conversationRefresh.setPushes(conversationPushes); }, [conversationPushes, conversationRefresh]); + useEffect(() => { + conversationRefresh.setPushes(conversationPushes && state.source !== "scrollback"); + }, [conversationPushes, state.source, conversationRefresh]); // A scrollback stand-in has no native file for the server to watch, and neither have settings // read off the agent's screen (a conversation not begun). Their source is the pane's own From bee51d62689ed624a76c816d61c0052ed0ceee19 Mon Sep 17 00:00:00 2001 From: Yu Xie Date: Sun, 25 Oct 2026 11:40:41 +0100 Subject: [PATCH 18/25] refactor(copilot): share per-pass ownership evidence and test fixtures Resolve each process once within a validation pass while retaining fresh post-read ownership and peer checks. Consolidate tool construction and isolated fixtures without removing ownership, continuity or stale-reference cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1fad73aa-cbc5-42dd-916b-64e5198bc512 --- server/conversation.ts | 17 +- server/copilot-store.ts | 28 ++- server/copilot.contract.test.ts | 58 ++----- server/copilot.test.ts | 292 ++++++++++++-------------------- server/copilot.ts | 37 ++-- 5 files changed, 163 insertions(+), 269 deletions(-) diff --git a/server/conversation.ts b/server/conversation.ts index 9e89c93e70..c3039bf266 100644 --- a/server/conversation.ts +++ b/server/conversation.ts @@ -59,7 +59,7 @@ 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 { copilotExplicitResume, copilotLoggedBinding, copilotLoggedSession, copilotTranscriptFile, copilotUnwrittenSession, openCopilotFile } from "./copilot-store.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"; import { forgetHermesTranscriptState, hermesConversationPage, hermesToolOutput, hermesTranscriptForPane } from "./hermes.ts"; @@ -992,9 +992,9 @@ async function copilotTranscriptPath(pane: HerdrPane, panes: readonly HerdrPane[ const info = await paneProcessInfo(peer.pane_id).catch(() => null); const processes = agentProcesses(info?.foreground_processes ?? [], "copilot"); if (processes.length === 1 && typeof processes[0]?.pid === "number") { - const live = copilotLoggedSession(processEnviron(processes[0].pid), processes[0].argv ?? [], processes[0].pid); - if (live !== null) { - claims.add(live); + const live = copilotProcessSession(processEnviron(processes[0].pid), processes[0].argv ?? [], processes[0].pid); + if (live?.logged) { + claims.add(live.sessionId); continue; } } @@ -1015,21 +1015,22 @@ async function copilotTranscriptPath(pane: HerdrPane, panes: readonly HerdrPane[ } const environment = processEnviron(foreground.pid); const argv = foreground.argv ?? []; - const logged = copilotLoggedBinding(environment, argv, foreground.pid); + const selected = copilotProcessSession(environment, argv, foreground.pid); + const logged = selected?.logged ? selected : null; if (logged !== null && !(await claimedByPeer(logged.sessionId))) { - const path = copilotTranscriptFile(logged.sessionId, environment, argv, foreground.pid); + const path = copilotTranscriptFile(logged.sessionId, selected); if (path !== null && nodePath.basename(nodePath.dirname(path)) === logged.sessionId) return { path, binding: `${foreground.pid}:${logged.generation}` }; } if (report != null) { if (await claimedByPeer(report.value)) throw new ConversationUnavailable("no_session_id"); if (logged === null) { - const path = copilotTranscriptFile(report.value, environment, argv, foreground.pid); + const path = copilotTranscriptFile(report.value, selected); if (path !== null && nodePath.basename(nodePath.dirname(path)) === report.value) return { path, binding: `${foreground.pid}:held:${path}` }; } } - const pending = copilotUnwrittenSession(environment, argv, foreground.pid); + const pending = copilotUnwrittenSession(selected, argv); if (pending !== null && (report == null || logged?.sessionId === pending.sessionId || report.value === pending.sessionId) && !(await claimedByPeer(pending.sessionId))) { throw new ConversationNotStarted(pending.sessionId, "copilot-transcript", pending.path); diff --git a/server/copilot-store.ts b/server/copilot-store.ts index 420d88eadb..39341cd309 100644 --- a/server/copilot-store.ts +++ b/server/copilot-store.ts @@ -200,7 +200,13 @@ export function heldSessions(pid: number, store: string): Set | null { return null; } -function processSessionFile(environment: readonly string[] | null, argv: readonly string[], pid: number): { sessionId: string; path: string; logged: boolean; generation?: string } | null { +type ProcessSession = { sessionId: string; path: string } & ( + | { logged: true; generation: string } + | { logged: false } +); + +/** Resolve once per validation pass; never reuse this evidence across a transcript read. */ +export function copilotProcessSession(environment: readonly string[] | null, argv: readonly string[], pid: number): ProcessSession | null { if (environment === null || !Number.isSafeInteger(pid) || pid < 1) return null; const values = (key: string) => environment.filter((item) => item.startsWith(`${key}=`)).map((item) => item.slice(key.length + 1)); const home = values("HOME"); @@ -245,9 +251,8 @@ function processSessionFile(environment: readonly string[] | null, argv: readonl } catch { return null; } } -/** Only the reported session, beneath the foreground process's own Copilot home. */ -export function copilotTranscriptFile(session: unknown, environment: readonly string[] | null, argv: readonly string[], pid: number): string | null { - const held = processSessionFile(environment, argv, pid); +/** Only the reported session, or the process-owned lifecycle selection. */ +export function copilotTranscriptFile(session: unknown, held: ProcessSession | null): string | null { if (held === null || (session === null ? !held.logged : typeof session !== "string" || !SESSION_ID.test(session) || held.sessionId !== session)) return null; try { const fd = openCopilotFile(held.path); @@ -257,21 +262,8 @@ export function copilotTranscriptFile(session: unknown, environment: readonly st } catch { return null; } } -/** A process-owned lifecycle log can supersede herdr's one-time session report. */ -export function copilotLoggedSession(environment: readonly string[] | null, argv: readonly string[], pid: number): string | null { - const found = processSessionFile(environment, argv, pid); - return found?.logged ? found.sessionId : null; -} - -/** The last process-owned registration identifies this selection, including a switch back to the same session. */ -export function copilotLoggedBinding(environment: readonly string[] | null, argv: readonly string[], pid: number): ForegroundBinding | null { - const found = processSessionFile(environment, argv, pid); - return found?.logged ? { sessionId: found.sessionId, generation: found.generation! } : null; -} - /** A process-held session with no transcript yet, never permission to read unreported history. */ -export function copilotUnwrittenSession(environment: readonly string[] | null, argv: readonly string[], pid: number): { sessionId: string; path: string } | null { - const held = processSessionFile(environment, argv, pid); +export function copilotUnwrittenSession(held: ProcessSession | null, argv: readonly string[]): { sessionId: string; path: string } | null { if (held === null) return null; if (!held.logged && copilotExplicitResume(argv)) return null; try { diff --git a/server/copilot.contract.test.ts b/server/copilot.contract.test.ts index fca21ae8ca..a64ca85018 100644 --- a/server/copilot.contract.test.ts +++ b/server/copilot.contract.test.ts @@ -21,6 +21,15 @@ let app: ReturnType; let workspace: string; let pane: string; +async function startFixture(pane: string, session: string, flags = "") { + await herdrRpc("pane.send_text", { pane_id: pane, text: `COPILOT_HOME=${home} node ${executable} ${session}${flags ? ` ${flags}` : ""}\n` }); + for (const deadline = Date.now() + 10_000;;) { + if ((await sessionSnapshot()).panes.find((p) => p.pane_id === pane)?.agent === "copilot") return; + if (Date.now() > deadline) throw new Error(`isolated Copilot stand-in not detected in ${pane}`); + await Bun.sleep(50); + } +} + beforeAll(async () => { if (process.platform !== "linux") return; for (const session of [id, other]) mkdirSync(join(home, "session-state", session), { recursive: true }); @@ -65,12 +74,7 @@ console.log("fixture ready");`); const created = await workspaceCreate({ cwd: root, label: "herdr-web-ui-test-copilot-api" }); workspace = created.workspace.workspace_id; pane = created.root_pane.pane_id; - await herdrRpc("pane.send_text", { pane_id: pane, text: `COPILOT_HOME=${home} node ${executable} ${id}\n` }); - for (const deadline = Date.now() + 10_000;;) { - if ((await sessionSnapshot()).panes.find((p) => p.pane_id === pane)?.agent === "copilot") break; - if (Date.now() > deadline) throw new Error("isolated Copilot stand-in not detected by Herdr"); - await Bun.sleep(50); - } + await startFixture(pane, id); app = createServer({ hostname: "127.0.0.1", port: 0, token: "", stateDir: join(root, "state"), tailscaleOwner: null }); }); @@ -122,12 +126,7 @@ it.skipIf(process.platform !== "linux")("binds only the reported UUID to the for const second = await workspaceCreate({ cwd: root, label: "herdr-web-ui-test-copilot-peer" }); try { const peer = second.root_pane.pane_id; - await herdrRpc("pane.send_text", { pane_id: peer, text: `COPILOT_HOME=${home} node ${executable} ${other}\n` }); - for (const deadline = Date.now() + 10_000;;) { - if ((await sessionSnapshot()).panes.find((p) => p.pane_id === peer)?.agent === "copilot") break; - if (Date.now() > deadline) throw new Error("peer Copilot process was not detected"); - await Bun.sleep(50); - } + await startFixture(peer, other); await report(other, 1, "herdr:copilot", peer); const peerUrl = `http://127.0.0.1:${app.port}/api/pane/conversation?pane_id=${encodeURIComponent(peer)}`; expect((await (await fetch(peerUrl)).json()).turns[0].parts[0].text).toBe("session B"); @@ -135,12 +134,7 @@ it.skipIf(process.platform !== "linux")("binds only the reported UUID to the for const duplicate = await workspaceCreate({ cwd: root, label: "herdr-web-ui-test-copilot-duplicate" }); try { const claimant = duplicate.root_pane.pane_id; - await herdrRpc("pane.send_text", { pane_id: claimant, text: `COPILOT_HOME=${home} node ${executable} ${id}\n` }); - for (const deadline = Date.now() + 10_000;;) { - if ((await sessionSnapshot()).panes.find((p) => p.pane_id === claimant)?.agent === "copilot") break; - if (Date.now() > deadline) throw new Error("duplicate claimant was not detected"); - await Bun.sleep(50); - } + await startFixture(claimant, id); await report(id, 1, "herdr:copilot", claimant); expect((await read()).source).toBe("scrollback"); const claimantUrl = `http://127.0.0.1:${app.port}/api/pane/conversation?pane_id=${encodeURIComponent(claimant)}`; @@ -168,12 +162,7 @@ it.skipIf(process.platform !== "linux")("binds only the reported UUID to the for if (Date.now() > deadline) throw new Error("isolated fixture did not exit"); await Bun.sleep(50); } - await herdrRpc("pane.send_text", { pane_id: pane, text: `COPILOT_HOME=${home} node ${executable} ${other}\n` }); - for (const deadline = Date.now() + 10_000;;) { - if ((await sessionSnapshot()).panes.find((p) => p.pane_id === pane)?.agent === "copilot") break; - if (Date.now() > deadline) throw new Error("replacement fixture not detected"); - await Bun.sleep(50); - } + await startFixture(pane, other); await report(other, 4); expect((await read()).turns[0]?.parts[0]?.text).toBe("session B"); expect((await fetch(`${url}&from=${encodeURIComponent(first.cursor!)}`)).status).toBe(409); @@ -197,12 +186,7 @@ it.skipIf(process.platform !== "linux")("reads a resumed CLI without marker or r const created = await workspaceCreate({ cwd: root, label: "herdr-web-ui-test-copilot-lifecycle" }); const current = created.root_pane.pane_id; try { - await herdrRpc("pane.send_text", { pane_id: current, text: `COPILOT_HOME=${home} node ${executable} ${resumed} --resume=${resumed} --lifecycle\n` }); - for (const deadline = Date.now() + 10_000;;) { - if ((await sessionSnapshot()).panes.find((p) => p.pane_id === current)?.agent === "copilot") break; - if (Date.now() > deadline) throw new Error("owned resumed fixture not detected"); - await Bun.sleep(50); - } + await startFixture(current, resumed, `--resume=${resumed} --lifecycle`); const url = `http://127.0.0.1:${app.port}/api/pane/conversation?pane_id=${encodeURIComponent(current)}`; const read = async () => (await fetch(url)).json() as Promise<{ source: string; turns: { parts: { text?: string; output_ref?: string }[] }[]; cursor?: string; history_id?: string; @@ -222,12 +206,7 @@ it.skipIf(process.platform !== "linux")("reads a resumed CLI without marker or r const duplicate = await workspaceCreate({ cwd: root, label: "herdr-web-ui-test-copilot-log-peer" }); try { const peer = duplicate.root_pane.pane_id; - await herdrRpc("pane.send_text", { pane_id: peer, text: `COPILOT_HOME=${home} node ${executable} ${resumed} --resume=${resumed} --lifecycle\n` }); - for (const deadline = Date.now() + 10_000;;) { - if ((await sessionSnapshot()).panes.find((p) => p.pane_id === peer)?.agent === "copilot") break; - if (Date.now() > deadline) throw new Error("log-only duplicate fixture not detected"); - await Bun.sleep(50); - } + await startFixture(peer, resumed, `--resume=${resumed} --lifecycle`); expect((await read()).source).toBe("scrollback"); const peerUrl = `http://127.0.0.1:${app.port}/api/pane/conversation?pane_id=${encodeURIComponent(peer)}`; expect((await (await fetch(peerUrl)).json()).source).toBe("scrollback"); @@ -298,12 +277,7 @@ it.skipIf(process.platform !== "linux")("never mistakes an explicitly resumed la const created = await workspaceCreate({ cwd: root, label: "herdr-web-ui-test-copilot-no-proof" }); const current = created.root_pane.pane_id; try { - await herdrRpc("pane.send_text", { pane_id: current, text: `COPILOT_HOME=${home} node ${executable} ${resumed} --resume=${resumed} --no-proof\n` }); - for (const deadline = Date.now() + 10_000;;) { - if ((await sessionSnapshot()).panes.find((p) => p.pane_id === current)?.agent === "copilot") break; - if (Date.now() > deadline) throw new Error("owned no-proof fixture not detected"); - await Bun.sleep(50); - } + await startFixture(current, resumed, `--resume=${resumed} --no-proof`); const url = `http://127.0.0.1:${app.port}/api/pane/conversation?pane_id=${encodeURIComponent(current)}`; expect((await (await fetch(url)).json()).source).toBe("scrollback"); expect((await fetch(`${url}&from=stale`)).status).toBe(409); diff --git a/server/copilot.test.ts b/server/copilot.test.ts index 1f9ca84b64..b6116a427d 100644 --- a/server/copilot.test.ts +++ b/server/copilot.test.ts @@ -3,16 +3,64 @@ import { appendFileSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileS import { tmpdir } from "node:os"; import { join } from "node:path"; import { parseCopilotTranscript } from "./copilot.ts"; -import { copilotLoggedBinding, copilotLoggedSession, copilotTranscriptFile, copilotUnwrittenSession, heldSessions } from "./copilot-store.ts"; +import { copilotProcessSession, copilotTranscriptFile as selectedTranscriptFile, copilotUnwrittenSession as selectedUnwrittenSession, heldSessions } from "./copilot-store.ts"; import { ConversationUnavailable, forgetTranscriptState, HistoryChanged, transcriptPage, transcriptToolOutput } from "./conversation.ts"; import { parseConversationMetadata } from "./conversation-metadata.ts"; const session = "12aa83d8-48bc-41f2-a498-7055c5c44a01"; +const other = "12aa83d8-48bc-41f2-a498-7055c5c44a02"; const line = (type: string, data: object, agentId?: string) => JSON.stringify({ type, data, id: crypto.randomUUID(), timestamp: "2026-10-21T00:00:00Z", parentId: null, ...(agentId ? { agentId } : {}) }) + "\n"; const start = line("session.start", { sessionId: session, copilotVersion: "1.0.102-1" }); const prompt = (text: string) => line("user.message", { content: text, transformedContent: "model-only text", turnId: crypto.randomUUID() }); const answer = (text: string, phase = "final_answer") => line("assistant.message", { content: text, phase, model: "fixture-model", turnId: crypto.randomUUID() }); +const copilotTranscriptFile = (session: unknown, ...args: Parameters) => + selectedTranscriptFile(session, copilotProcessSession(...args)); +const copilotUnwrittenSession = (...args: Parameters) => + selectedUnwrittenSession(copilotProcessSession(...args), args[1]); +const register = (id: string, action = "Registering") => `2026-10-24T00:00:00.000Z [INFO] ${action} foreground session: ${id}\n`; + +async function logFixture(held: readonly string[]) { + const root = mkdtempSync(join(tmpdir(), "copilot-log-")); + for (const id of [session, other]) mkdirSync(join(root, "session-state", id), { recursive: true }); + mkdirSync(join(root, "logs")); + const child = Bun.spawn(["node", "-e", ` + const {openSync, closeSync} = require("node:fs"); + const root = process.argv[1]; + const log = root + "/logs/process-100-" + process.pid + ".log"; + for (const id of process.argv.slice(2)) openSync(root + "/session-state/" + id + "/inuse." + process.pid + ".hold", "w"); + let fd = openSync(log, "w"); + console.log("ready"); + require("node:readline").createInterface({input: process.stdin}).on("line", (command) => { + if (command === "close") closeSync(fd); + else if (command === "open") fd = openSync(log, "a"); + else if (command === "extra") openSync(root + "/logs/process-200-" + process.pid + ".log", "w"); + else throw new Error("unexpected fixture command: " + command); + console.log("ok"); + }); + `, root, ...held], { stdin: "pipe", stdout: "pipe", stderr: "inherit" }); + const reader = child.stdout.getReader(); + const close = async () => { + child.kill(); + await child.exited; + rmSync(root, { recursive: true, force: true }); + }; + try { + expect(new TextDecoder().decode((await reader.read()).value)).toContain("ready"); + } catch (error) { await close(); throw error; } + const environment = [`COPILOT_HOME=${root}`]; + return { + root, environment, pid: child.pid, log: join(root, "logs", `process-100-${child.pid}.log`), close, + binding: () => { + const selected = copilotProcessSession(environment, ["copilot"], child.pid); + return selected?.logged ? selected : null; + }, + command: async (value: string) => { + child.stdin.write(`${value}\n`); + expect(new TextDecoder().decode((await reader.read()).value)).toContain("ok"); + }, + }; +} it("parses persisted Copilot messages and tool rounds once, with failures and recorded metadata", () => { const text = start + prompt("Visible prompt") @@ -65,9 +113,11 @@ it("keeps unphased completed prose available as an answer, and explicit commenta expect(turns.at(-1)?.parts).toEqual([{ kind: "text", text: "work", phase: "commentary" }, { kind: "text", text: "unphased answer" }]); }); -it("renders completed Autopilot summaries as final replies without duplicating an explicit answer or a child completion", () => { +it.each(["assistant.message", "tool.execution_start"])("renders Autopilot summaries from %s without duplicating an explicit answer or a child completion", (type) => { const completion = (id: string, summary: string) => - line("assistant.message", { toolRequests: [{ toolCallId: id, name: "task_complete", arguments: { summary } }] }) + line(type, type === "assistant.message" + ? { toolRequests: [{ toolCallId: id, name: "task_complete", arguments: { summary } }] } + : { toolCallId: id, toolName: "task_complete", arguments: { summary } }) + line("tool.execution_complete", { toolCallId: id, success: true, result: { content: "Task complete." } }); const transcript = start + prompt("first") + answer("Working", "commentary") + completion("done-1", "Completed first task.") @@ -329,76 +379,40 @@ it.skipIf(process.platform !== "linux")("uses only the process home and a canoni }); it.skipIf(process.platform !== "linux")("keeps report-and-marker fallback for a complete CLI log without lifecycle events", async () => { - const root = mkdtempSync(join(tmpdir(), "copilot-log-no-lifecycle-")); - const directory = join(root, "session-state", session); - mkdirSync(directory, { recursive: true }); - mkdirSync(join(root, "logs")); - const path = join(directory, "events.jsonl"); - writeFileSync(path, start + prompt("existing conversation")); - const child = Bun.spawn(["node", "-e", ` - const {openSync} = require("node:fs"); - const root = process.argv[1]; - openSync(root + "/session-state/${session}/inuse." + process.pid + ".hold", "w"); - openSync(root + "/logs/process-100-" + process.pid + ".log", "w"); - console.log("ready"); - process.stdin.on("data", () => { - openSync(root + "/logs/process-200-" + process.pid + ".log", "w"); - console.log("opened"); - }); - `, root], { stdin: "pipe", stdout: "pipe", stderr: "inherit" }); - const reader = child.stdout.getReader(); + const fixture = await logFixture([session]); + const { root, log, pid, environment, binding } = fixture; + const path = join(root, "session-state", session, "events.jsonl"); try { - expect(new TextDecoder().decode((await reader.read()).value)).toContain("ready"); - const log = join(root, "logs", `process-100-${child.pid}.log`); - const environment = [`COPILOT_HOME=${root}`]; + writeFileSync(path, start + prompt("existing conversation")); const argv = ["copilot", `--resume=${session}`]; for (const content of ["", "2026-10-23T00:00:00.000Z [INFO] CLI started\n"]) { writeFileSync(log, content); - expect(copilotLoggedBinding(environment, argv, child.pid)).toBeNull(); - expect(copilotTranscriptFile(session, environment, argv, child.pid)).toBe(path); - expect(copilotTranscriptFile(null, environment, argv, child.pid)).toBeNull(); - expect(copilotTranscriptFile(session.slice(0, -2) + "02", environment, argv, child.pid)).toBeNull(); + expect(binding()).toBeNull(); + expect(copilotTranscriptFile(session, environment, argv, pid)).toBe(path); + expect(copilotTranscriptFile(null, environment, argv, pid)).toBeNull(); + expect(copilotTranscriptFile(other, environment, argv, pid)).toBeNull(); } - writeFileSync(log, `2026-10-23T00:00:00.000Z [INFO] Unregistering foreground session: ${session}\n`); - expect(copilotTranscriptFile(session, environment, argv, child.pid)).toBeNull(); + writeFileSync(log, register(session, "Unregistering")); + expect(copilotTranscriptFile(session, environment, argv, pid)).toBeNull(); writeFileSync(log, "x".repeat(16 * 1024 * 1024) + "\n"); - expect(copilotTranscriptFile(session, environment, argv, child.pid)).toBeNull(); + expect(copilotTranscriptFile(session, environment, argv, pid)).toBeNull(); writeFileSync(log, "2026-10-23T00:00:00.000Z [INFO] CLI started"); - expect(copilotTranscriptFile(session, environment, argv, child.pid)).toBeNull(); - writeFileSync(log, `2026-10-23T00:00:00.000Z [INFO] Registering foreground session: ${session}\n`); - expect(copilotLoggedBinding(environment, argv, child.pid)?.sessionId).toBe(session); + expect(copilotTranscriptFile(session, environment, argv, pid)).toBeNull(); + writeFileSync(log, register(session)); + expect(binding()?.sessionId).toBe(session); writeFileSync(log, "2026-10-23T00:00:00.000Z [INFO] CLI restarted\n"); - expect(copilotTranscriptFile(session, environment, argv, child.pid)).toBeNull(); - expect(copilotTranscriptFile(session, environment, argv, child.pid)).toBeNull(); - child.stdin.write("extra\n"); - expect(new TextDecoder().decode((await reader.read()).value)).toContain("opened"); - expect(copilotTranscriptFile(session, environment, argv, child.pid)).toBeNull(); - } finally { - child.kill(); - await child.exited; - rmSync(root, { recursive: true, force: true }); - } + expect(copilotTranscriptFile(session, environment, argv, pid)).toBeNull(); + expect(copilotTranscriptFile(session, environment, argv, pid)).toBeNull(); + await fixture.command("extra"); + expect(copilotTranscriptFile(session, environment, argv, pid)).toBeNull(); + } finally { await fixture.close(); } }); for (const scenario of ["rewrite", "gap"] as const) { it.skipIf(process.platform !== "linux")(`recovers foreground discovery after a log ${scenario}`, async () => { - const root = mkdtempSync(join(tmpdir(), "copilot-log-continuity-")); - const other = "12aa83d8-48bc-41f2-a498-7055c5c44a02"; - for (const id of [session, other]) mkdirSync(join(root, "session-state", id), { recursive: true }); - mkdirSync(join(root, "logs")); - const child = Bun.spawn(["node", "-e", ` - const {openSync} = require("node:fs"); - openSync(process.argv[1] + "/logs/process-100-" + process.pid + ".log", "w"); - console.log("ready"); - process.stdin.resume(); - `, root], { stdin: "pipe", stdout: "pipe", stderr: "inherit" }); - const reader = child.stdout.getReader(); + const fixture = await logFixture([]); + const { log, binding } = fixture; try { - expect(new TextDecoder().decode((await reader.read()).value)).toContain("ready"); - const log = join(root, "logs", `process-100-${child.pid}.log`); - const register = (id: string) => `2026-10-22T00:00:00.000Z [INFO] Registering foreground session: ${id}\n`; - const options = [`COPILOT_HOME=${root}`]; - const binding = () => copilotLoggedBinding(options, ["copilot"], child.pid); appendFileSync(log, register(session)); const original = binding(); expect(original?.sessionId).toBe(session); @@ -422,155 +436,75 @@ for (const scenario of ["rewrite", "gap"] as const) { expect(binding()?.sessionId).toBe(session); expect(binding()?.generation).not.toBe(original?.generation); } - } finally { - child.kill(); - await child.exited; - rmSync(root, { recursive: true, force: true }); - } + } finally { await fixture.close(); } }); } it.skipIf(process.platform !== "linux")("recovers from an ambiguous unregister only after a fresh registration", async () => { - const root = mkdtempSync(join(tmpdir(), "copilot-log-unregister-")); - const other = "12aa83d8-48bc-41f2-a498-7055c5c44a02"; - for (const id of [session, other]) mkdirSync(join(root, "session-state", id), { recursive: true }); - mkdirSync(join(root, "logs")); - const child = Bun.spawn(["node", "-e", ` - require("node:fs").openSync(process.argv[1] + "/logs/process-100-" + process.pid + ".log", "w"); - console.log("ready"); - process.stdin.resume(); - `, root], { stdin: "pipe", stdout: "pipe", stderr: "inherit" }); + const fixture = await logFixture([]); + const { log, binding } = fixture; try { - expect(new TextDecoder().decode((await child.stdout.getReader().read()).value)).toContain("ready"); - const log = join(root, "logs", `process-100-${child.pid}.log`); - const event = (action: string, id: string) => `2026-10-24T00:00:00.000Z [INFO] ${action} foreground session: ${id}\n`; - const binding = () => copilotLoggedBinding([`COPILOT_HOME=${root}`], ["copilot"], child.pid); - appendFileSync(log, event("Registering", session) + event("Registering", other) + event("Unregistering", session)); + appendFileSync(log, register(session) + register(other) + register(session, "Unregistering")); expect(binding()).toBeNull(); expect(binding()).toBeNull(); appendFileSync(log, "ordinary append\n"); expect(binding()).toBeNull(); - appendFileSync(log, event("Registering", session)); + appendFileSync(log, register(session)); expect(binding()?.sessionId).toBe(session); - appendFileSync(log, event("Unregistering", session)); + appendFileSync(log, register(session, "Unregistering")); expect(binding()).toBeNull(); - appendFileSync(log, event("Registering", other)); + appendFileSync(log, register(other)); expect(binding()?.sessionId).toBe(other); - } finally { - child.kill(); - await child.exited; - rmSync(root, { recursive: true, force: true }); - } + } finally { await fixture.close(); } }); it.skipIf(process.platform !== "linux")("keeps an initially incomplete log unavailable after its descriptor closes", async () => { - const root = mkdtempSync(join(tmpdir(), "copilot-log-closed-")); - const directory = join(root, "session-state", session); - mkdirSync(directory, { recursive: true }); - mkdirSync(join(root, "logs")); - const path = join(directory, "events.jsonl"); - writeFileSync(path, start + prompt("existing conversation")); - const child = Bun.spawn(["node", "-e", ` - const {openSync, closeSync} = require("node:fs"); - const root = process.argv[1]; - const log = root + "/logs/process-100-" + process.pid + ".log"; - openSync(root + "/session-state/${session}/inuse." + process.pid + ".hold", "w"); - let fd = openSync(log, "w"); - console.log("ready"); - process.stdin.on("data", (input) => { - if (input.toString().trim() === "close") closeSync(fd); - else fd = openSync(log, "a"); - console.log("ok"); - }); - `, root], { stdin: "pipe", stdout: "pipe", stderr: "inherit" }); - const reader = child.stdout.getReader(); + const fixture = await logFixture([session]); + const { root, log, environment, pid } = fixture; + const path = join(root, "session-state", session, "events.jsonl"); try { - expect(new TextDecoder().decode((await reader.read()).value)).toContain("ready"); - const log = join(root, "logs", `process-100-${child.pid}.log`); - const read = () => copilotTranscriptFile(session, [`COPILOT_HOME=${root}`], ["copilot"], child.pid); - writeFileSync(log, `2026-10-24T00:00:00.000Z [INFO] Unregistering foreground session: ${session}`); + writeFileSync(path, start + prompt("existing conversation")); + const read = () => copilotTranscriptFile(session, environment, ["copilot"], pid); + writeFileSync(log, register(session, "Unregistering").trimEnd()); expect(read()).toBeNull(); - child.stdin.write("close\n"); - expect(new TextDecoder().decode((await reader.read()).value)).toContain("ok"); + await fixture.command("close"); expect(read()).toBeNull(); - child.stdin.write("open\n"); - expect(new TextDecoder().decode((await reader.read()).value)).toContain("ok"); + await fixture.command("open"); writeFileSync(log, "ordinary complete line\n"); expect(read()).toBeNull(); expect(read()).toBeNull(); - appendFileSync(log, `2026-10-24T00:00:00.000Z [INFO] Registering foreground session: ${session}\n`); + appendFileSync(log, register(session)); expect(read()).toBe(path); - } finally { - child.kill(); - await child.exited; - rmSync(root, { recursive: true, force: true }); - } + } finally { await fixture.close(); } }); it.skipIf(process.platform !== "linux")("follows process-owned foreground evidence through fork and switch-back with multiple holds", async () => { - const root = mkdtempSync(join(tmpdir(), "copilot-foreground-")); - const home = join(root, "home"); - const logs = join(home, "logs"); - const other = "12aa83d8-48bc-41f2-a498-7055c5c44a02"; - const options = [`COPILOT_HOME=${home}`]; + const fixture = await logFixture([session, other]); + const { root, log, environment: options, pid, binding } = fixture; const args = ["copilot", "--resume", session]; - for (const id of [session, other]) { - const directory = join(home, "session-state", id); - mkdirSync(directory, { recursive: true }); - writeFileSync(join(directory, "events.jsonl"), line("session.start", { sessionId: id }) + prompt(id)); - } - mkdirSync(logs); - const child = Bun.spawn(["node", "-e", ` - const { openSync, appendFileSync } = require("node:fs"); - const { join } = require("node:path"); - const root = process.argv[1]; - const ids = process.argv.slice(2); - const path = join(root, "logs", "process-100-" + process.pid + ".log"); - openSync(path, "w"); - for (const id of ids) openSync(join(root, "session-state", id, "inuse." + process.pid + ".hold"), "w"); - const event = (action, id) => appendFileSync(path, new Date().toISOString() + " [INFO] " + action + " foreground session: " + id + "\\n"); - event("Registering", ids[0]); - process.stdin.setEncoding("utf8"); - process.stdin.on("data", (input) => { for (const command of input.trim().split("\\n")) { - if (command === "fork") { event("Unregistering", ids[0]); event("Registering", ids[1]); } - if (command === "back") { event("Unregistering", ids[1]); event("Registering", ids[0]); } - if (command === "incomplete") appendFileSync(path, new Date().toISOString() + " [INFO] Registering foreground session: " + ids[1]); - console.log("ok"); - } }); - console.log("ready"); - `, home, session, other], { stdin: "pipe", stdout: "pipe", stderr: "inherit" }); - const reader = child.stdout.getReader(); try { - expect(new TextDecoder().decode((await reader.read()).value)).toContain("ready"); - const command = async (value: string) => { - child.stdin.write(`${value}\n`); - expect(new TextDecoder().decode((await reader.read()).value)).toContain("ok"); - }; - expect(heldSessions(child.pid, join(home, "session-state"))?.size).toBe(2); - expect(copilotLoggedSession(options, args, child.pid)).toBe(session); - expect(copilotTranscriptFile(null, options, args, child.pid)).toBe(join(home, "session-state", session, "events.jsonl")); - const log = join(logs, `process-100-${child.pid}.log`); + for (const id of [session, other]) writeFileSync(join(root, "session-state", id, "events.jsonl"), line("session.start", { sessionId: id }) + prompt(id)); + appendFileSync(log, register(session)); + expect(heldSessions(pid, join(root, "session-state"))?.size).toBe(2); + expect(binding()?.sessionId).toBe(session); + expect(copilotTranscriptFile(null, options, args, pid)).toBe(join(root, "session-state", session, "events.jsonl")); for (let n = 0; n < 17; n++) { appendFileSync(log, `unrelated ${".".repeat(1024 * 1024)}\n`); - expect(copilotLoggedSession(options, args, child.pid)).toBe(session); + expect(binding()?.sessionId).toBe(session); } - expect(copilotLoggedSession(options, args, child.pid)).toBe(session); - await command("fork"); - expect(copilotLoggedSession(options, args, child.pid)).toBe(other); - expect(copilotTranscriptFile(session, options, args, child.pid)).toBeNull(); - expect(copilotTranscriptFile(other, options, args, child.pid)).toBe(join(home, "session-state", other, "events.jsonl")); - await command("back"); - expect(copilotLoggedSession(options, args, child.pid)).toBe(session); + expect(binding()?.sessionId).toBe(session); + appendFileSync(log, register(session, "Unregistering") + register(other)); + expect(binding()?.sessionId).toBe(other); + expect(copilotTranscriptFile(session, options, args, pid)).toBeNull(); + expect(copilotTranscriptFile(other, options, args, pid)).toBe(join(root, "session-state", other, "events.jsonl")); + appendFileSync(log, register(other, "Unregistering") + register(session)); + expect(binding()?.sessionId).toBe(session); appendFileSync(log, "ordinary partial line"); - expect(copilotLoggedSession(options, args, child.pid)).toBeNull(); + expect(binding()).toBeNull(); appendFileSync(log, "\n"); - expect(copilotLoggedSession(options, args, child.pid)).toBe(session); - await command("incomplete"); - expect(copilotLoggedSession(options, args, child.pid)).toBeNull(); - expect(copilotTranscriptFile(session, options, args, child.pid)).toBeNull(); - } finally { - child.kill(); - await child.exited; - rmSync(root, { recursive: true, force: true }); - } + expect(binding()?.sessionId).toBe(session); + appendFileSync(log, register(other).trimEnd()); + expect(binding()).toBeNull(); + expect(copilotTranscriptFile(session, options, args, pid)).toBeNull(); + } finally { await fixture.close(); } }); diff --git a/server/copilot.ts b/server/copilot.ts index f9f9f0a822..79e5cbf4fa 100644 --- a/server/copilot.ts +++ b/server/copilot.ts @@ -55,6 +55,19 @@ export function parseCopilotTranscript(jsonl: string, maxTurns = MAX_TURNS): Con finished = false; return turn; }; + const addTool = (data: Row, ts: string | null, name: string, intention: string | null = null): void => { + const id = data.toolCallId; + if (typeof id !== "string" || !CALL_ID.test(id) || pending.has(id)) return; + const args = row(data.arguments) ?? {}; + const part: Extract = { + kind: "tool", name, summary: text(data.toolTitle) ?? intention ?? toolSummary(name, args), + input: JSON.stringify(args, null, 2).slice(0, 16_000), output: "", + }; + assistant(ts).parts.push(part); + pending.set(id, part); + const summary = text(args.summary); + if (name === "task_complete" && summary !== null) completionTools.set(id, summary); + }; for (const { type, data, timestamp } of copilotRecords(jsonl)) { if (type === "user.message") { const content = text(data.content); @@ -77,31 +90,11 @@ export function parseCopilotTranscript(jsonl: string, maxTurns = MAX_TURNS): Con }); for (const raw of requests) { const request = row(raw); - if (!request || typeof request.toolCallId !== "string" || !CALL_ID.test(request.toolCallId) - || pending.has(request.toolCallId)) continue; - const name = text(request.name) ?? "tool"; - const args = row(request.arguments) ?? {}; - const part: Extract = { - kind: "tool", name, summary: text(request.toolTitle) ?? text(request.intentionSummary) ?? toolSummary(name, args), - input: JSON.stringify(args, null, 2).slice(0, 16_000), output: "", - }; - turn.parts.push(part); - pending.set(request.toolCallId, part); - if (name === "task_complete" && text(args.summary) !== null) completionTools.set(request.toolCallId, text(args.summary)!); + if (request) addTool(request, timestamp, text(request.name) ?? "tool", text(request.intentionSummary)); } if (data.phase === "final_answer") finished = true; } else if (type === "tool.execution_start") { - const id = data.toolCallId; - if (typeof id !== "string" || !CALL_ID.test(id) || pending.has(id)) continue; - const name = text(data.toolName) ?? "tool"; - const args = row(data.arguments) ?? {}; - const part: Extract = { - kind: "tool", name, summary: text(data.toolTitle) ?? toolSummary(name, args), - input: JSON.stringify(args, null, 2).slice(0, 16_000), output: "", - }; - assistant(timestamp).parts.push(part); - pending.set(id, part); - if (name === "task_complete" && text(args.summary) !== null) completionTools.set(id, text(args.summary)!); + addTool(data, timestamp, text(data.toolName) ?? "tool"); } else if (type === "tool.execution_complete") { const id = data.toolCallId; const part = typeof id === "string" ? pending.get(id) : undefined; From 038ded8c66215da28033262e075c3120405cb038 Mon Sep 17 00:00:00 2001 From: Yu Xie Date: Sun, 25 Oct 2026 16:13:04 +0100 Subject: [PATCH 19/25] docs(copilot): update the upstream agent row without duplicating it Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1fad73aa-cbc5-42dd-916b-64e5198bc512 --- docs/guide.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/guide.md b/docs/guide.md index b1ff4de0df..ad5536438f 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -156,7 +156,6 @@ Every agent herdr runs shows up with its live status, terminal and alerts. The c | --- | --- | --- | | **Claude Code** | Native transcript, resolved through herdr | ✓ approvals, questions, plan and menu picks, and the `/model` list (picked for the session) | | **Codex** | Native rollout, with tool results and commentary/final phases | ✓ approvals and questions, including queued ones, and the `/model` lists (picked for the session) | -| **Copilot CLI** | Linux native transcript when the foreground session is verified. See [Copilot setup and limits](copilot-chat.md). | — recorded choices are read-only; answer questions and approvals in Terminal | | **omp** | Native session file, in the store the pane's omp process writes to: `~/.omp/agent`, a profile's, or where its `--session-dir`, or the `PI_CODING_AGENT_SESSION_DIR`, `PI_CODING_AGENT_DIR`, `PI_CONFIG_DIR` or XDG data and state directories it was started with, put it (absolute paths only, not values from omp's `.env` files; on Windows only `~/.omp` and its profiles) | ✓ approvals, questions, and the model picker of `/model` and `/switch` (picked for the session: the chat sends `/model` as `/switch`, since omp's own `/model` opens its role hub) | | **omo** | Native session file, found through the pane's process tree | ✓ the `/model` list (picked for the session); other menus — use Terminal | | **gjc** | Native session file reported by the web UI's GJC extension, verified against the pane's process | — use Terminal | @@ -168,7 +167,7 @@ Every agent herdr runs shows up with its live status, terminal and alerts. The c | **Hermes** | Native SQLite session store | — use Terminal | | **Antigravity (agy)** | **Experimental; macOS untested.** Native ledger, selected by the running process’s held conversation lock. Linux native-tested; Windows uses terminal text. See [Agy behavior and limits](agy-chat.md). | — use Terminal | | **Letta Code** | The terminal's text. Choose **Letta Code** in **New workspace**, **New tab with agent…** or **New worktree** where `letta` is installed, or run `letta` in a herdr pane's **Terminal**. | — use Terminal | -| **GitHub Copilot CLI** | The terminal's text. Choose **GitHub Copilot** in **New workspace**, **New tab with agent…** or **New worktree** where `copilot` is installed, or run `copilot` in a herdr pane's **Terminal**. | — use Terminal | +| **GitHub Copilot CLI** | Linux native transcript when the foreground session is verified. See [Copilot setup and limits](copilot-chat.md). | — recorded choices are read-only; answer questions and approvals in Terminal | | **Anything else** | The terminal's text | — use Terminal | GJC chat and GJC done alerts use a native extension rather than SDK session queries. From From 4cef1e3771dd0de03d1f55aaf65a025cae7f9a7a Mon Sep 17 00:00:00 2001 From: devswha Date: Mon, 26 Oct 2026 01:09:22 +0900 Subject: [PATCH 20/25] fix(copilot): validate history and reuse request ownership evidence --- docs/copilot-chat.md | 12 ++-- server/conversation.ts | 16 +++-- server/copilot-store.ts | 41 +++++++---- server/copilot.contract.test.ts | 13 ++++ server/copilot.test.ts | 123 ++++++++++++++++++++++++++++++-- server/copilot.ts | 43 ++++++++--- 6 files changed, 207 insertions(+), 41 deletions(-) diff --git a/docs/copilot-chat.md b/docs/copilot-chat.md index 77be371286..0683761155 100644 --- a/docs/copilot-chat.md +++ b/docs/copilot-chat.md @@ -45,13 +45,17 @@ links. The marker-and-report fallback cannot detect an unobserved switch away an The reader scans at most 16 MiB of lifecycle-log catch-up per lookup and checkpoints complete reads, including unavailable selections. Ambiguity remains unavailable until a fresh registration; it does not permanently block later registrations. -An incomplete trailing line temporarily blocks Chat. If log continuity remains intact, -the reader processes that line once it is complete; it never serves the older binding -while the line could contain an unfinished foreground switch. +Continuity verification hashes the entire consumed prefix in 64 KiB chunks, including +bytes outside the catch-up window. An incomplete trailing line keeps the last complete +binding only when its prefix rules out a lifecycle event. A partial timestamp or possible +foreground switch temporarily blocks Chat until the line is complete. Observation state is bounded to 256 live process identities. Exited or replaced processes release slots; at capacity, untracked processes use Terminal rather than forgetting a live process's invalid evidence. -Transcript pages use the shared bounded pager, and ownership is checked again after reading. +Transcript pages use the shared bounded pager. After reading, the pane's own process is +checked again using the request's existing peer claims. Unsupported or malformed complete +message records use Terminal instead of an empty Chat. +Tool-output searches read at most 32 MiB; an output beyond that limit is unavailable. ## Verification diff --git a/server/conversation.ts b/server/conversation.ts index 94f5672904..200dd07ac7 100644 --- a/server/conversation.ts +++ b/server/conversation.ts @@ -992,9 +992,11 @@ async function ompTranscriptPath(paneId: string, cwd: string): Promise { } /** A process-owned lifecycle registration, or a trusted report plus a unique held marker. */ -async function copilotTranscriptPath(pane: HerdrPane, panes: readonly HerdrPane[]): Promise<{ path: string; binding: string }> { +async function copilotTranscriptPath(pane: HerdrPane, panes: readonly HerdrPane[], previousClaims?: ReadonlySet): Promise<{ path: string; binding: string; peerClaims: ReadonlySet }> { const report = pane.agent_session; - let peerClaims: Promise> | undefined; + // A request shares its peer snapshot across the read; the pane's own process + // and lifecycle evidence are still validated afresh before returning history. + let peerClaims: Promise> | undefined = previousClaims === undefined ? undefined : Promise.resolve(previousClaims); const gatherPeerClaims = async (): Promise> => { const claims = new Set(); for (const peer of panes) { @@ -1030,14 +1032,14 @@ async function copilotTranscriptPath(pane: HerdrPane, panes: readonly HerdrPane[ if (logged !== null && !(await claimedByPeer(logged.sessionId))) { const path = copilotTranscriptFile(logged.sessionId, selected); if (path !== null && nodePath.basename(nodePath.dirname(path)) === logged.sessionId) - return { path, binding: `${foreground.pid}:${logged.generation}` }; + return { path, binding: `${foreground.pid}:${logged.generation}`, peerClaims: await peerClaims! }; } if (report != null) { if (await claimedByPeer(report.value)) throw new ConversationUnavailable("no_session_id"); if (logged === null) { const path = copilotTranscriptFile(report.value, selected); if (path !== null && nodePath.basename(nodePath.dirname(path)) === report.value) - return { path, binding: `${foreground.pid}:held:${path}` }; + return { path, binding: `${foreground.pid}:held:${path}`, peerClaims: await peerClaims! }; } } const pending = copilotUnwrittenSession(selected, argv); @@ -1052,7 +1054,7 @@ async function copilotTranscriptPath(pane: HerdrPane, panes: readonly HerdrPane[ type ResolvedTranscript = | ({ path: string; codexHome?: string; agy?: AgyStore; piAgentDir?: string | null } & ( | { source: Exclude } - | { source: "copilot-transcript"; binding: string } + | { source: "copilot-transcript"; binding: string; peerClaims: ReadonlySet } )) | { source: "opencode-transcript"; path: string; session: string } | { source: "hermes-transcript"; path: string; sessionId: string }; @@ -1322,7 +1324,7 @@ 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); + const current = await copilotTranscriptPath(pane, snapshot.panes, resolved.peerClaims); if (current.path !== resolved.path || current.binding !== resolved.binding) throw new HistoryChanged(); return bindCopilotAnswer(answer, prefix); } @@ -1840,7 +1842,7 @@ export async function toolOutput(paneId: string, ref: string, codexHome?: string 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).catch(() => null); + const current = await copilotTranscriptPath(pane, snapshot.panes, resolved.peerClaims).catch(() => null); if (current?.path !== resolved.path || current.binding !== resolved.binding) return null; } return output; diff --git a/server/copilot-store.ts b/server/copilot-store.ts index 39341cd309..f5a76894f7 100644 --- a/server/copilot-store.ts +++ b/server/copilot-store.ts @@ -1,6 +1,6 @@ import { constants, closeSync, fstatSync, lstatSync, openSync, opendirSync, readlinkSync, readSync, realpathSync, statSync } from "node:fs"; import { basename, dirname, isAbsolute, join, relative, sep } from "node:path"; -import { createHash, randomUUID } from "node:crypto"; +import { createHash, randomUUID, type Hash } from "node:crypto"; import { processAlive, processStartedAt } from "./process-start.ts"; const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -12,21 +12,21 @@ const FOREGROUND_EVENT = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+Z \[INFO\] (Regist interface ForegroundBinding { sessionId: string; generation: string } interface ForegroundCheckpoint { dev: number; ino: number; path: string; size: number; mtimeMs: number; ctimeMs: number; - boundary: string; epoch: string; binding: ForegroundBinding | null | undefined; + hash: Hash; epoch: string; binding: ForegroundBinding | null | undefined; } const foregroundCheckpoints = new Map(); const foregroundLogProcesses = new Map(); -/** Check consumed content at both ends before treating the next read as an append. */ -function logBoundary(fd: number, size: number): string | null { - const length = Math.min(size, HEADER_BYTES); - const bytes = Buffer.alloc(length); +/** Revalidate every consumed byte: unchanged boundary samples cannot prove an append. */ +function logPrefix(fd: number, size: number): Hash | null { + const bytes = Buffer.alloc(HEADER_BYTES); const hash = createHash("sha256"); - for (const position of [0, Math.max(0, size - length)]) { + for (let position = 0; position < size; position += bytes.length) { + const length = Math.min(bytes.length, size - position); if (readSync(fd, bytes, 0, length, position) !== length) return null; - hash.update(bytes); + hash.update(bytes.subarray(0, length)); } - return hash.digest("hex"); + return hash; } /** The latest complete lifecycle event in the CLI's own open log, never the newest transcript. */ @@ -92,20 +92,20 @@ function loggedForeground(pid: number, logDirectories: readonly string[]): Foreg const named = statSync(target); if (!stat.isFile() || stat.dev !== named.dev || stat.ino !== named.ino) return unavailable(); const cached = foregroundCheckpoints.get(pid); + const prefix = cached && stat.size >= cached.size ? logPrefix(fd, cached.size) : null; const same = cached?.dev === stat.dev && cached.ino === stat.ino && cached.path === target && stat.size >= cached.size && stat.size - cached.size <= LOG_WINDOW_BYTES && (stat.size !== cached.size || (stat.mtimeMs === cached.mtimeMs && stat.ctimeMs === cached.ctimeMs)) - && logBoundary(fd, cached.size) === cached.boundary; + && prefix?.copy().digest("hex") === cached.hash.copy().digest("hex"); const epoch = same ? cached.epoch : randomUUID(); const start = same ? cached.size : Math.max(0, stat.size - LOG_WINDOW_BYTES); const bytes = Buffer.alloc(stat.size - start); const read = readSync(fd, bytes, 0, bytes.length, start); - const boundary = logBoundary(fd, stat.size); + const hash = same ? prefix : logPrefix(fd, start); const after = fstatSync(fd); - if (read !== bytes.length || boundary === null || after.size !== stat.size + if (read !== bytes.length || hash === null || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs) return unavailable(); const text = bytes.subarray(0, read).toString("utf8"); - if (text && !text.endsWith("\n")) return undefined; const skip = start === 0 || same ? 0 : text.indexOf("\n") + 1; const complete = text.slice(skip, text.lastIndexOf("\n") + 1); if (start > 0 && !complete && !same) return unavailable(); @@ -121,10 +121,21 @@ function loggedForeground(pid: number, logDirectories: readonly string[]): Foreg } position += Buffer.byteLength(line) + 1; } + const completeBytes = bytes.lastIndexOf(0x0a) + 1; + hash.update(bytes.subarray(0, completeBytes)); foregroundCheckpoints.set(pid, { - dev: stat.dev, ino: stat.ino, path: target, size: stat.size, - mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs, boundary, epoch, binding: selected, + dev: stat.dev, ino: stat.ino, path: target, size: start + completeBytes, + mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs, hash, epoch, binding: selected, }); + const tail = text.slice(text.lastIndexOf("\n") + 1); + if (tail) { + // A timestamp can still become a lifecycle event. Keep the complete + // checkpoint, but serve it only once the tail rules out a switch. + const message = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+Z \[INFO\] (.*)$/.exec(tail)?.[1]; + const switchPrefixes = ["Registering foreground session: ", "Unregistering foreground session: "]; + if (selected == null || (/^\d/.test(tail) && (message === undefined + || switchPrefixes.some((event) => event.startsWith(message) || message.startsWith(event))))) return undefined; + } found = selected; } finally { closeSync(fd); } } catch { return unavailable(); } diff --git a/server/copilot.contract.test.ts b/server/copilot.contract.test.ts index a64ca85018..907b1dae74 100644 --- a/server/copilot.contract.test.ts +++ b/server/copilot.contract.test.ts @@ -102,6 +102,19 @@ it.skipIf(process.platform !== "linux")("binds only the reported UUID to the for await report(id, 1, "untrusted"); expect((await read()).source).toBe("scrollback"); await report(id, 2); + const header = event("session.start", { sessionId: id }); + writeFileSync(file(id), header); + expect((await read()).source).toBe("copilot-transcript"); + expect((await read()).turns).toEqual([]); + for (const record of [ + event("user.message", { content: [{ type: "text", text: "unsupported prompt" }] }), + event("assistant.message", { content: [{ type: "text", text: "unsupported answer" }] }), + "malformed complete record\n", + ]) { + writeFileSync(file(id), header + record); + expect((await read()).source).toBe("scrollback"); + } + writeFileSync(file(id), fixture(id, "session A")); const first = await read(); expect(first.source).toBe("copilot-transcript"); expect(first.history_id).not.toBe(external.history_id); diff --git a/server/copilot.test.ts b/server/copilot.test.ts index b6116a427d..9c67acbed9 100644 --- a/server/copilot.test.ts +++ b/server/copilot.test.ts @@ -1,11 +1,14 @@ -import { expect, it } from "bun:test"; +import { expect, it, spyOn } from "bun:test"; import { appendFileSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { parseCopilotTranscript } from "./copilot.ts"; import { copilotProcessSession, copilotTranscriptFile as selectedTranscriptFile, copilotUnwrittenSession as selectedUnwrittenSession, heldSessions } from "./copilot-store.ts"; -import { ConversationUnavailable, forgetTranscriptState, HistoryChanged, transcriptPage, transcriptToolOutput } from "./conversation.ts"; +import { ConversationUnavailable, forgetTranscriptState, HistoryChanged, paneConversation, toolOutput, transcriptPage, transcriptToolOutput } from "./conversation.ts"; import { parseConversationMetadata } from "./conversation-metadata.ts"; +import * as herdr from "./herdr/client.ts"; +import * as omo from "./omo.ts"; +import type { SessionSnapshot } from "../shared/protocol.ts"; const session = "12aa83d8-48bc-41f2-a498-7055c5c44a01"; const other = "12aa83d8-48bc-41f2-a498-7055c5c44a02"; @@ -197,7 +200,7 @@ it("pages bounded native turns and observes append, torn lines, and same-file re expect(older.turns[0]?.parts[0]).toEqual({ kind: "text", text: "prompt 0" }); appendFileSync(path, '{"type":"user.message","data":'); expect(transcriptPage("copilot-transcript", path).turns).toEqual(first.turns); - appendFileSync(path, '\n' + line("session.resume", { selectedModel: "fixture-model" }) + prompt("after resume") + answer("still here")); + appendFileSync(path, '{"content":"completed partial prompt"}}\n' + line("session.resume", { selectedModel: "fixture-model" }) + prompt("after resume") + answer("still here")); const resumed = transcriptPage("copilot-transcript", path); expect(resumed.history_id).toBe(first.history_id); expect(resumed.turns.at(-1)?.parts[0]).toEqual({ kind: "text", text: "still here", phase: "final_answer" }); @@ -284,6 +287,67 @@ it("skips oversized unrelated records and bounds selected output before parsing" } finally { forgetTranscriptState(); rmSync(dir, { recursive: true, force: true }); } }); +it("bounds total tool-output scans even when a reference is missing or beyond the limit", () => { + const dir = mkdtempSync(join(tmpdir(), "copilot-output-scan-")); + const path = join(dir, "events.jsonl"); + try { + const request = line("tool.execution_start", { toolCallId: "chosen", toolName: "shell" }); + const output = line("tool.execution_complete", { toolCallId: "chosen", result: { content: "found" } }); + writeFileSync(path, start + request + output); + expect(transcriptToolOutput("copilot-transcript", path, "chosen")).toBe("found"); + writeFileSync(path, start + line("system.message", { content: "padding".repeat(1024 * 1024) }).repeat(6) + request + output); + expect(transcriptToolOutput("copilot-transcript", path, "chosen")).toBeNull(); + expect(transcriptToolOutput("copilot-transcript", path, "missing")).toBeNull(); + } finally { forgetTranscriptState(); rmSync(dir, { recursive: true, force: true }); } +}); + +it.skipIf(process.platform !== "linux")("reuses peer claims across conversation and tool reads while rechecking the pane process", async () => { + const own = await logFixture([]); + const peer = await logFixture([]); + const ownPane = "copilot-own"; + const peerPane = "copilot-peer"; + const calls: string[] = []; + const rpc = spyOn(herdr, "herdrRpc").mockResolvedValue({ process_info: { foreground_processes: [] } }); + const info = spyOn(herdr, "paneProcessInfo").mockImplementation(async (paneId) => { + calls.push(paneId); + return { pane_id: paneId, foreground_processes: [{ pid: paneId === ownPane ? own.pid : peer.pid, name: "copilot", argv: ["copilot"] }] }; + }); + const environment = spyOn(omo, "processEnviron").mockImplementation((pid) => pid === own.pid ? own.environment : peer.environment); + const snapshot = { + panes: [ownPane, peerPane].map((pane_id) => ({ pane_id, agent: "copilot", cwd: own.root })), + agents: [], + } as unknown as SessionSnapshot; + const snapshots = spyOn(herdr, "sessionSnapshot").mockResolvedValue(snapshot); + try { + appendFileSync(own.log, register(session)); + appendFileSync(peer.log, register(other)); + writeFileSync(join(own.root, "session-state", session, "events.jsonl"), start + prompt("own history") + + line("tool.execution_start", { toolCallId: "chosen", toolName: "shell" }) + + line("tool.execution_complete", { toolCallId: "chosen", result: { content: "output ".repeat(1000) } })); + const conversation = await paneConversation(ownPane); + expect(conversation.source).toBe("copilot-transcript"); + expect(calls.filter((pane) => pane === peerPane)).toHaveLength(1); + expect(calls.filter((pane) => pane === ownPane)).toHaveLength(2); + const part = conversation.turns.at(-1)?.parts.at(-1); + if (part?.kind !== "tool" || !part.output_ref) throw new Error("tool output ref missing"); + calls.length = 0; + expect(await toolOutput(ownPane, part.output_ref)).toBe("output ".repeat(1000)); + expect(calls.filter((pane) => pane === peerPane)).toHaveLength(1); + expect(calls.filter((pane) => pane === ownPane)).toHaveLength(2); + calls.length = 0; + info.mockImplementation(async (paneId) => { + calls.push(paneId); + if (paneId === ownPane && calls.filter((pane) => pane === ownPane).length === 2) return { pane_id: paneId, foreground_processes: [] }; + return { pane_id: paneId, foreground_processes: [{ pid: paneId === ownPane ? own.pid : peer.pid, name: "copilot", argv: ["copilot"] }] }; + }); + await expect(paneConversation(ownPane)).rejects.toThrow(ConversationUnavailable); + } finally { + snapshots.mockRestore(); environment.mockRestore(); info.mockRestore(); rpc.mockRestore(); + forgetTranscriptState(); + await own.close(); await peer.close(); + } +}); + it.skipIf(process.platform !== "linux")("uses only the process home and a canonical regular file with the matching native header", async () => { const root = mkdtempSync(join(tmpdir(), "copilot-store-")); const custom = join(root, "custom"); @@ -440,6 +504,57 @@ for (const scenario of ["rewrite", "gap"] as const) { }); } +it.skipIf(process.platform !== "linux")("detects a growing mid-log rewrite with unchanged boundary bytes", async () => { + const fixture = await logFixture([]); + const { log, binding } = fixture; + try { + const padding = "ordinary line\n".repeat(6000); + writeFileSync(log, padding + register(session) + padding); + const original = binding(); + expect(original?.sessionId).toBe(session); + writeFileSync(log, padding + register(other) + padding + "append\n"); + expect(binding()?.sessionId).toBe(other); + expect(binding()?.generation).not.toBe(original?.generation); + } finally { await fixture.close(); } +}); + +it.each([ + line("user.message", { content: [{ type: "text", text: "unsupported" }] }), + line("assistant.message", { content: [{ type: "text", text: "unsupported" }] }), + line("user.message", {}), + JSON.stringify({ type: "assistant.message", data: [] }) + "\n", + "malformed complete record\n", +])("rejects unreadable history instead of returning an empty native chat: %s", (record) => { + const dir = mkdtempSync(join(tmpdir(), "copilot-unreadable-")); + const path = join(dir, "events.jsonl"); + try { + writeFileSync(path, start); + expect(transcriptPage("copilot-transcript", path).turns).toEqual([]); + appendFileSync(path, record); + expect(() => transcriptPage("copilot-transcript", path)).toThrow(ConversationUnavailable); + } finally { forgetTranscriptState(); rmSync(dir, { recursive: true, force: true }); } +}); + +it.skipIf(process.platform !== "linux")("keeps complete bindings through safe partial lines but blocks unfinished switches", async () => { + const fixture = await logFixture([]); + const { log, binding } = fixture; + try { + appendFileSync(log, register(session)); + const original = binding(); + for (const tail of ["ordinary partial line", "2026-10-24T00:00:00.000Z [INFO] Writing session"]) { + appendFileSync(log, tail); + expect(binding()?.generation).toBe(original?.generation); + expect(binding()?.sessionId).toBe(session); + appendFileSync(log, "\n"); + expect(binding()?.sessionId).toBe(session); + } + appendFileSync(log, register(other).trimEnd()); + expect(binding()).toBeNull(); + appendFileSync(log, "\n"); + expect(binding()?.sessionId).toBe(other); + } finally { await fixture.close(); } +}); + it.skipIf(process.platform !== "linux")("recovers from an ambiguous unregister only after a fresh registration", async () => { const fixture = await logFixture([]); const { log, binding } = fixture; @@ -500,7 +615,7 @@ it.skipIf(process.platform !== "linux")("follows process-owned foreground eviden appendFileSync(log, register(other, "Unregistering") + register(session)); expect(binding()?.sessionId).toBe(session); appendFileSync(log, "ordinary partial line"); - expect(binding()).toBeNull(); + expect(binding()?.sessionId).toBe(session); appendFileSync(log, "\n"); expect(binding()?.sessionId).toBe(session); appendFileSync(log, register(other).trimEnd()); diff --git a/server/copilot.ts b/server/copilot.ts index 79e5cbf4fa..cfdf5ba691 100644 --- a/server/copilot.ts +++ b/server/copilot.ts @@ -2,6 +2,7 @@ import type { ConversationPart, ConversationTurn } from "../shared/protocol.ts"; import { readSync } from "node:fs"; import { MAX_TURNS, toolSummary } from "./transcript-records.ts"; import { trimOutput } from "./tool-output.ts"; +import { ConversationUnavailable } from "./conversation-shared.ts"; type Row = Record; const row = (value: unknown): Row | null => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Row : null; @@ -15,15 +16,29 @@ export function copilotRootRecord(value: unknown): boolean { } /** Lines written by the CLI, not its ephemeral streaming updates or nested agents. */ -export function copilotRecords(jsonl: string): { type: string; data: Row; timestamp: string | null }[] { +export function copilotRecords(jsonl: string, strict = false): { type: string; data: Row; timestamp: string | null }[] { const records: { type: string; data: Row; timestamp: string | null }[] = []; - for (const line of jsonl.split("\n")) { + const lines = jsonl.split("\n"); + for (const [index, line] of lines.entries()) { + if (!line.trim()) continue; let value: unknown; - try { value = JSON.parse(line); } catch { continue; } + try { value = JSON.parse(line); } catch { + // An unfinished final append is retried; complete unreadable records are + // not proof of an empty conversation. + if (strict && index < lines.length - 1) throw new ConversationUnavailable("transcript_unreadable"); + continue; + } const entry = row(value); - if (!entry || typeof entry.type !== "string" || !copilotRootRecord(entry)) continue; + if (!entry || typeof entry.type !== "string") { + if (strict) throw new ConversationUnavailable("transcript_unreadable"); + continue; + } + if (!copilotRootRecord(entry)) continue; const data = row(entry.data); - if (data === null) continue; + if (data === null) { + if (strict) throw new ConversationUnavailable("transcript_unreadable"); + continue; + } records.push({ type: entry.type, data, timestamp: typeof entry.timestamp === "string" ? entry.timestamp : null }); } return records; @@ -68,8 +83,9 @@ export function parseCopilotTranscript(jsonl: string, maxTurns = MAX_TURNS): Con const summary = text(args.summary); if (name === "task_complete" && summary !== null) completionTools.set(id, summary); }; - for (const { type, data, timestamp } of copilotRecords(jsonl)) { + for (const { type, data, timestamp } of copilotRecords(jsonl, true)) { if (type === "user.message") { + if (typeof data.content !== "string") throw new ConversationUnavailable("transcript_unreadable"); const content = text(data.content); if (content !== null) { flushCompletion(); @@ -79,6 +95,8 @@ export function parseCopilotTranscript(jsonl: string, maxTurns = MAX_TURNS): Con finished = false; } } else if (type === "assistant.message") { + if ((data.content != null && typeof data.content !== "string") + || (data.content == null && !Array.isArray(data.toolRequests))) throw new ConversationUnavailable("transcript_unreadable"); const content = text(data.content); const requests = Array.isArray(data.toolRequests) ? data.toolRequests : []; if (content === null && requests.length === 0) continue; @@ -121,7 +139,7 @@ export function parseCopilotTranscript(jsonl: string, maxTurns = MAX_TURNS): Con * Scan a pinned descriptor forward in 64 KiB reads, holding at most one 8 MiB record. * Oversized unrelated lines are discarded without parsing; an oversized line that * mentions the requested ID fails closed. I/O is linear up to the first result (or - * EOF for a missing ID), never a whole-file read/index on the server event loop. + * EOF for a missing ID), capped at 32 MiB total on the server event loop. */ export function copilotOutput(fd: number, ref: string, outputMax: number): string | null { if (!CALL_ID.test(ref)) return null; @@ -152,7 +170,11 @@ export function copilotOutput(fd: number, ref: string, outputMax: number): strin const content = row(data.result)?.content; return typeof content === "string" ? content.length > outputMax ? `${content.slice(0, outputMax)}\n… trimmed` : content : null; }; - for (let count; (count = readSync(fd, chunk, 0, chunk.length, null)) > 0;) { + const scanMax = 32 * 1024 * 1024; + for (let scanned = 0; scanned < scanMax;) { + const count = readSync(fd, chunk, 0, Math.min(chunk.length, scanMax - scanned), null); + if (count === 0) return consume() ?? null; + scanned += count; for (let at = 0; at < count;) { const newline = chunk.indexOf(0x0a, at); const end = newline < 0 || newline >= count ? count : newline; @@ -170,7 +192,6 @@ export function copilotOutput(fd: number, ref: string, outputMax: number): strin at = newline + 1; } } - // JSONL may end in a complete object without a newline. Use the same bounded - // record path as newline-terminated records; malformed tails still return null. - return consume() ?? null; + // The limit can cut a record: never interpret that prefix as a complete one. + return null; } From 6851a0e9e5290d1dfd3f28c759cfc85ee03aec0a Mon Sep 17 00:00:00 2001 From: devswha Date: Mon, 26 Oct 2026 01:09:30 +0900 Subject: [PATCH 21/25] fix(chat): scope Copilot recovery polling and align browser checks --- docs/development.md | 4 +--- scripts/chat-fallback-browser-qa.ts | 31 +++++++++++++++++++++++++++++ scripts/chat-fallback-fixture.tsx | 2 +- scripts/copilot-chat-browser-qa.ts | 26 ++++++++++++++++-------- src/components/ChatView.tsx | 4 ++-- 5 files changed, 53 insertions(+), 14 deletions(-) diff --git a/docs/development.md b/docs/development.md index d827b0b90a..5124287cd5 100644 --- a/docs/development.md +++ b/docs/development.md @@ -149,9 +149,7 @@ the test runner. A report is evidence, not an authorization to merge or publish. A local pass is not CI's: the PC has its own Node (CI pins 22), its own cores and its own system libraries. The browser lane uses the lockfile's Chromium, which it downloads into Playwright's -cache on first use. The native Copilot browser fixture requires Chromium sandboxing: on hosted -Ubuntu CI it uses the image's installed Chrome, whose AppArmor policy permits its user-namespace -sandbox, rather than the downloaded binary. It keeps sandboxing enabled and fails if unavailable. +cache on first use. `HERDR_TEST_SHARDS=4` runs four integration files at a time, each with an isolated herdr session. Local checks and hosted CI default to one; self-hosted CI uses four. diff --git a/scripts/chat-fallback-browser-qa.ts b/scripts/chat-fallback-browser-qa.ts index ede0864e87..99c29f329b 100644 --- a/scripts/chat-fallback-browser-qa.ts +++ b/scripts/chat-fallback-browser-qa.ts @@ -9,6 +9,7 @@ import { CONTENT_SECURITY_POLICY } from "../server/static.ts"; import { chromePath } from "./chrome-path.ts"; import { parseClaudeTranscript } from "../server/conversation.ts"; import type { ConversationTurn } from "../shared/protocol.ts"; +import type {} from "./chat-fallback-fixture.tsx"; const root = realpathSync(mkdtempSync(join(tmpdir(), "herdr-chat-fallback-"))); let browser: Awaited> | undefined; let server: ReturnType | undefined; @@ -171,6 +172,36 @@ try { console.log("PASS Claude hook narration and replies stay visible outside folded work; the tool remains inside"); } assert.deepEqual(errors, []); + structured = false; + const polling = await browser.newPage(); + let pollingReads = 0; + polling.on("request", (request) => { + if (new URL(request.url()).pathname === "/api/pane/conversation") pollingReads += 1; + }); + await polling.clock.install(); + await polling.clock.pauseAt(new Date()); + await polling.addInitScript(() => { + localStorage.setItem("ovis:settings", JSON.stringify({ language: "en" })); + }); + await polling.goto(`http://127.0.0.1:${server.port}/?pushes`); + await polling.locator(".chat-terminal-fallback summary").waitFor(); + const codexReads = pollingReads; + await polling.clock.runFor(2000); + assert.equal(pollingReads, codexReads, "non-Copilot terminal fallback keeps its 10s backstop"); + const codexBackstop = polling.waitForResponse((response) => new URL(response.url()).pathname === "/api/pane/conversation"); + await polling.clock.runFor(8000); + await codexBackstop; + assert.equal(pollingReads, codexReads + 1); + const changedAgent = polling.waitForResponse((response) => new URL(response.url()).pathname === "/api/pane/conversation"); + await polling.evaluate(() => window.chatFallbackQA.agent("copilot")); + await changedAgent; + const copilotReads = pollingReads; + const copilotPoll = polling.waitForResponse((response) => new URL(response.url()).pathname === "/api/pane/conversation"); + await polling.clock.runFor(2000); + await copilotPoll; + assert.equal(pollingReads, copilotReads + 1, "only Copilot terminal fallback uses the 2s recovery poll"); + await polling.close(); + console.log("PASS fallback polling keeps 10s for other agents and 2s for Copilot under a controlled clock"); console.log("chat fallback: browser OK (no hidden terminal reads, opened stalled output cannot block structured refresh, late cleanup, non-agent inline output, fallback error announcement and accurate empty state)"); } finally { diff --git a/scripts/chat-fallback-fixture.tsx b/scripts/chat-fallback-fixture.tsx index a1e3201c86..f317ab8018 100644 --- a/scripts/chat-fallback-fixture.tsx +++ b/scripts/chat-fallback-fixture.tsx @@ -9,6 +9,6 @@ function Fixture() { const [pane, setPane] = useState("agent"); const [refresh, setRefresh] = useState(0); window.chatFallbackQA = { pane: setPane, agent: setAgent, refresh: () => setRefresh((key) => key + 1) }; - return
; + return
; } createRoot(document.getElementById("root")!).render(); diff --git a/scripts/copilot-chat-browser-qa.ts b/scripts/copilot-chat-browser-qa.ts index 93654cd332..7e8b2d7f04 100644 --- a/scripts/copilot-chat-browser-qa.ts +++ b/scripts/copilot-chat-browser-qa.ts @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { chromium } from "playwright-core"; import { createServer } from "../server/index.ts"; -import { herdrRpc, sessionSnapshot, workspaceClose, workspaceCreate } from "../server/herdr/client.ts"; +import { herdrRpc, paneProcessInfo, paneRead, sessionSnapshot, workspaceClose, workspaceCreate } from "../server/herdr/client.ts"; import { UsageService } from "../server/usage.ts"; import { chromePath } from "./chrome-path.ts"; @@ -65,7 +65,11 @@ console.log("fixture ready");`); await herdrRpc("pane.send_text", { pane_id: pane, text: `COPILOT_HOME=${home} node ${executable} ${id} ${input}\n` }); for (const deadline = Date.now() + 10_000;;) { if ((await sessionSnapshot()).panes.find((candidate) => candidate.pane_id === pane)?.agent === "copilot") break; - if (Date.now() > deadline) throw new Error("owned Copilot fixture process was not detected"); + if (Date.now() > deadline) throw new Error(`owned Copilot fixture process was not detected: ${JSON.stringify({ + pane: (await sessionSnapshot()).panes.find((candidate) => candidate.pane_id === pane), + process: await paneProcessInfo(pane), + screen: await paneRead({ paneId: pane, source: "visible", format: "text" }), + })}`); await Bun.sleep(50); } await herdrRpc("pane.report_agent_session", { pane_id: pane, agent: "copilot", source: "herdr:copilot", @@ -76,11 +80,7 @@ console.log("fixture ready");`); const body = await (await fetch(endpoint)).json() as { source: string }; assert.equal(body.source, "copilot-transcript"); - // Ubuntu's installed Chrome has an AppArmor policy allowing its user-namespace sandbox; - // the downloaded Playwright binary does not have that policy on hosted runners. - const installedChrome = "/opt/google/chrome/chrome"; - const executablePath = process.env.CI && existsSync(installedChrome) ? installedChrome : chromePath(); - browser = await chromium.launch({ executablePath, headless: true, chromiumSandbox: true }); + browser = await chromium.launch({ executablePath: chromePath(), headless: true, args: ["--no-sandbox"] }); const page = await browser.newPage({ viewport: { width: 1100, height: 800 }, locale: "en-US" }); const errors: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); @@ -155,8 +155,18 @@ console.log("fixture ready");`); await log.getByText("Conversation recovered without switching lenses.", { exact: true }).waitFor({ timeout: 4000 }); await page.locator(".chat-terminal-fallback").waitFor({ state: "detached" }); await page.unroute(conversationRoute); + const readableHistory = readFileSync(transcript, "utf8"); + for (const type of ["user.message", "assistant.message"]) { + writeFileSync(transcript, event("session.start", { sessionId: id }) + + event(type, { content: [{ type: "text", text: "unsupported native content" }] })); + await page.locator(".chat-terminal-fallback summary").waitFor(); + assert.equal(await log.getByText("Conversation recovered without switching lenses.", { exact: true }).count(), 0); + writeFileSync(transcript, readableHistory); + await log.getByText("Conversation recovered without switching lenses.", { exact: true }).waitFor(); + await page.locator(".chat-terminal-fallback").waitFor({ state: "detached" }); + } assert.deepEqual(errors, []); - console.log("PASS Copilot native HTTP/React, tool detail, append, desktop/mobile, PTY send and transient recovery"); + console.log("PASS Copilot native HTTP/React, tool detail, append, desktop/mobile, PTY send, transient recovery and unsupported-content terminal fallback"); } finally { const failures: unknown[] = []; try { await browser?.close(); } catch (error) { failures.push(error); } diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index d663d02a27..f08a7b31dc 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -1365,8 +1365,8 @@ export const ChatView = memo(function ChatView({ paneId, presentationKey, finish }, [paneId, invalidationKey, visible, conversationRefresh]); useEffect(() => { - conversationRefresh.setPushes(conversationPushes && state.source !== "scrollback"); - }, [conversationPushes, state.source, conversationRefresh]); + conversationRefresh.setPushes(conversationPushes && (agent !== "copilot" || state.source !== "scrollback")); + }, [conversationPushes, state.source, conversationRefresh, agent]); // A scrollback stand-in has no native file for the server to watch, and neither have settings // read off the agent's screen (a conversation not begun). Their source is the pane's own From 0a763cfc80b901da2bf2c1b03c186d894b2939ce Mon Sep 17 00:00:00 2001 From: Yu Xie Date: Sun, 25 Oct 2026 17:27:45 +0100 Subject: [PATCH 22/25] test(copilot): add redacted recorded session fixture Add only the remaining real-format fixture, a focused reader regression and explicit provenance. Preserve all maintainer implementation follow-ups unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1fad73aa-cbc5-42dd-916b-64e5198bc512 --- docs/copilot-chat.md | 12 +++++++++--- server/copilot.test.ts | 14 +++++++++++++- server/fixtures/copilot-recorded.jsonl | 7 +++++++ 3 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 server/fixtures/copilot-recorded.jsonl diff --git a/docs/copilot-chat.md b/docs/copilot-chat.md index 0683761155..84b07c9c54 100644 --- a/docs/copilot-chat.md +++ b/docs/copilot-chat.md @@ -66,6 +66,12 @@ bun run check run --build bun scripts/copilot-chat-browser-qa.ts bun run check fast ``` -The unit, HTTP contract and browser fixtures use fictional transcripts and disposable -processes. They establish reader behavior, not a live model run or compatibility with every -Copilot CLI release. Private transcripts and process logs are not published as fixtures. +The HTTP contract and browser fixtures use fictional transcripts and disposable processes. +`server/fixtures/copilot-recorded.jsonl` is a redacted seven-record excerpt from a real CLI +session whose header identifies Copilot 1.0.102. Field names, nested collections, scalar types, +empty values and record/phase discriminators are preserved. Text, paths, identifiers, models, +timestamps and telemetry values are replaced; intervening records are omitted, so some parent +IDs point outside the excerpt. The unit test covers a root prompt with task ancestry, a +tool-only assistant message, its request/result and recorded assistant text. It does not +establish 1.0.103 lifecycle ordering, universal marker holding, a subagent discriminator or +compatibility with every CLI release. No raw private transcript or process log is published. diff --git a/server/copilot.test.ts b/server/copilot.test.ts index 9c67acbed9..7c27bad58a 100644 --- a/server/copilot.test.ts +++ b/server/copilot.test.ts @@ -1,5 +1,5 @@ import { expect, it, spyOn } from "bun:test"; -import { appendFileSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { parseCopilotTranscript } from "./copilot.ts"; @@ -86,6 +86,18 @@ it("parses persisted Copilot messages and tool rounds once, with failures and re expect(parseConversationMetadata(text, "copilot-transcript")).toEqual({ model: "fixture-model", reasoning_effort: null }); }); +it("reads a redacted recorded CLI session with root task ancestry and a tool-only assistant message", () => { + const recorded = readFileSync(new URL("./fixtures/copilot-recorded.jsonl", import.meta.url), "utf8"); + const turns = parseCopilotTranscript(recorded); + expect(turns.map(({ role }) => role)).toEqual(["user", "assistant"]); + expect(turns[0]?.parts).toEqual([{ kind: "text", text: "Recorded fixture text." }]); + expect(turns[1]?.parts).toEqual([ + { kind: "tool", name: "shell", summary: "redacted", input: "{}", output: "Recorded fixture text." }, + { kind: "text", text: "Recorded fixture text.", phase: "commentary" }, + ]); + expect(parseConversationMetadata(recorded, "copilot-transcript").model).toBe("fixture-model"); +}); + it("keeps root prompts carrying parentAgentTaskId and pages at those prompts", () => { const dir = mkdtempSync(join(tmpdir(), "copilot-root-page-")); const path = join(dir, "events.jsonl"); diff --git a/server/fixtures/copilot-recorded.jsonl b/server/fixtures/copilot-recorded.jsonl new file mode 100644 index 0000000000..2e78c6330b --- /dev/null +++ b/server/fixtures/copilot-recorded.jsonl @@ -0,0 +1,7 @@ +{"type":"session.start","data":{"sessionId":"6ff55b57-2b5f-5022-ad8b-524631694aa1","version":1,"producer":"copilot-agent","copilotVersion":"1.0.102","startTime":"2026-01-01T00:00:00.000Z","contextTier":null,"context":{"cwd":"/fixture/project"},"alreadyInUse":false,"remoteSteerable":false},"id":"c6ae59cd-4100-5d14-850d-f8202b722a98","timestamp":"2026-01-01T00:00:00.000Z","parentId":null} +{"type":"user.message","data":{"content":"Recorded fixture text.","transformedContent":"redacted","messageId":"8ff9f83c-0bdf-58d9-be4e-b11369508e29","supportedNativeDocumentMimeTypes":[],"delivery":"redacted","interactionId":"dd118527-f9ee-5d0f-8637-949e975f1105","turnId":"cbb21cb2-75b1-5657-831a-a9df31933706","parentAgentTaskId":"2b569001-54db-5dbe-8779-36341ed5c2ce","responsesReasoning":{"model":"fixture-model","initialEffort":"redacted","effort":"redacted"}},"id":"5fbeae4f-facc-561d-9836-f25d74e9748f","timestamp":"2026-01-01T00:00:00.000Z","parentId":"dc768e8c-836e-5035-ac83-2a108749076e"} +{"type":"assistant.message","data":{"messageId":"52904de1-b643-5092-9f0a-99a59c6b3403","originatingMessageId":"8ff9f83c-0bdf-58d9-be4e-b11369508e29","model":"fixture-model","providerId":"redacted","content":"","toolRequests":[{"toolCallId":"afdec090-f321-5892-be6e-70932c259c7e","name":"shell","arguments":{},"type":"function","toolTitle":"redacted"}],"interactionId":"dd118527-f9ee-5d0f-8637-949e975f1105","turnId":"cbb21cb2-75b1-5657-831a-a9df31933706","rte":true,"apiCallId":"f8eb6c2f-5b1b-5dd6-84e3-693090e0c10c","serverTools":{"provider":"redacted"}},"id":"81b09d48-ceae-5b05-852c-8d2a53419783","timestamp":"2026-01-01T00:00:00.000Z","parentId":"ee5125ac-881e-5fae-8beb-74463c8f8e56"} +{"type":"tool.execution_start","data":{"toolCallId":"afdec090-f321-5892-be6e-70932c259c7e","toolName":"shell","arguments":{},"turnId":"cbb21cb2-75b1-5657-831a-a9df31933706","model":"fixture-model","toolTitle":"redacted"},"id":"fc0ee7e8-f51a-57b8-822d-ded401c7b51c","timestamp":"2026-01-01T00:00:00.000Z","parentId":"81b09d48-ceae-5b05-852c-8d2a53419783"} +{"type":"tool.execution_complete","data":{"toolCallId":"afdec090-f321-5892-be6e-70932c259c7e","model":"fixture-model","interactionId":"dd118527-f9ee-5d0f-8637-949e975f1105","turnId":"cbb21cb2-75b1-5657-831a-a9df31933706","rte":true,"success":true,"result":{"content":"Recorded fixture text.","detailedContent":"redacted"},"toolTelemetry":{"metrics":{"context_token_limit":0.0,"context_current_tokens":0.0,"large_output_threshold_bytes":0.0,"result_token_count":0}}},"id":"2c95e2ee-bb52-58a0-95c6-76981fe89daa","timestamp":"2026-01-01T00:00:00.000Z","parentId":"fc0ee7e8-f51a-57b8-822d-ded401c7b51c"} +{"type":"assistant.message","data":{"messageId":"3c0ff558-cb54-5219-b7c3-c57625f8dbef","originatingMessageId":"8ff9f83c-0bdf-58d9-be4e-b11369508e29","model":"fixture-model","providerId":"redacted","content":"Recorded fixture text.","toolRequests":[],"interactionId":"dd118527-f9ee-5d0f-8637-949e975f1105","turnId":"6a058638-8bbc-5e8d-8384-ca4c255fc23d","reasoningOpaque":"redacted","reasoningText":"redacted","encryptedContent":"redacted","phase":"commentary","rte":true,"apiCallId":"6f1a00c0-c2be-57ba-b1eb-1e634f40e41c","serverTools":{"provider":"redacted"},"reasoningBlocks":{"provider":"redacted","blocks":[{"content":[],"encrypted_content":"redacted","id":"b0660ed1-6e39-5123-bb5a-40862290cb43","summary":[{"text":"redacted","type":"summary_text"}],"type":"reasoning"}]}},"id":"06b7cc54-4d4a-5353-9393-ccfc2405bcca","timestamp":"2026-01-01T00:00:00.000Z","parentId":"a5ec5fb0-030c-5b2f-b347-80ebab011275"} +{"type":"assistant.turn_end","data":{"turnId":"6a058638-8bbc-5e8d-8384-ca4c255fc23d"},"id":"12f6cede-6da6-5d05-9ddd-a7090d546605","timestamp":"2026-01-01T00:00:00.000Z","parentId":"bea3e5a1-a67f-5a78-9771-5ee5ddae4401"} From c465ae2c455f3104e3b3ca037e973879b2235da4 Mon Sep 17 00:00:00 2001 From: devswha Date: Mon, 26 Oct 2026 01:32:18 +0900 Subject: [PATCH 23/25] fix(copilot): validate tool requests and bound log verification --- docs/copilot-chat.md | 6 ++- scripts/copilot-chat-browser-qa.ts | 12 +++-- server/copilot-store.ts | 52 +++++++++++--------- server/copilot.contract.test.ts | 2 + server/copilot.test.ts | 78 ++++++++++++++++++++++++++++-- server/copilot.ts | 10 +++- 6 files changed, 127 insertions(+), 33 deletions(-) diff --git a/docs/copilot-chat.md b/docs/copilot-chat.md index 84b07c9c54..5353b37068 100644 --- a/docs/copilot-chat.md +++ b/docs/copilot-chat.md @@ -45,8 +45,10 @@ links. The marker-and-report fallback cannot detect an unobserved switch away an The reader scans at most 16 MiB of lifecycle-log catch-up per lookup and checkpoints complete reads, including unavailable selections. Ambiguity remains unavailable until a fresh registration; it does not permanently block later registrations. -Continuity verification hashes the entire consumed prefix in 64 KiB chunks, including -bytes outside the catch-up window. An incomplete trailing line keeps the last complete +Unchanged file identity, size and timestamps reuse the last verification. Changed logs +revalidate the entire consumed prefix in 64 KiB chunks, including bytes outside the catch-up +window; logs over 64 MiB use Terminal without reading or hashing them. +An incomplete trailing line keeps the last complete binding only when its prefix rules out a lifecycle event. A partial timestamp or possible foreground switch temporarily blocks Chat until the line is complete. Observation state is bounded to 256 live process identities. Exited or replaced processes diff --git a/scripts/copilot-chat-browser-qa.ts b/scripts/copilot-chat-browser-qa.ts index 7e8b2d7f04..46f673f501 100644 --- a/scripts/copilot-chat-browser-qa.ts +++ b/scripts/copilot-chat-browser-qa.ts @@ -1,7 +1,7 @@ /** Owned native Copilot-shaped fixture -> Herdr -> HTTP -> built React chat. */ import "./test-herdr.ts"; import assert from "node:assert/strict"; -import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { appendFileSync, chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { chromium } from "playwright-core"; @@ -44,6 +44,7 @@ try { // marker; its stdin remains the pane's PTY so the regular composer can reach it. writeFileSync(executable, `import { appendFileSync, mkdirSync, openSync } from "node:fs"; import { join } from "node:path"; +process.title = "copilot"; const home = process.env.COPILOT_HOME; const marker = join(home, "session-state", process.argv[2], \`inuse.\${process.pid}.hold\`); mkdirSync(join(home, "session-state", process.argv[2]), { recursive: true }); @@ -156,9 +157,14 @@ console.log("fixture ready");`); await page.locator(".chat-terminal-fallback").waitFor({ state: "detached" }); await page.unroute(conversationRoute); const readableHistory = readFileSync(transcript, "utf8"); - for (const type of ["user.message", "assistant.message"]) { + for (const [type, data] of [ + ["user.message", { content: [{ type: "text", text: "unsupported native content" }] }], + ["assistant.message", { content: [{ type: "text", text: "unsupported native content" }] }], + ["assistant.message", { toolRequests: ["unreadable request"] }], + ["assistant.message", { content: "", toolRequests: { toolCallId: "call", name: "shell" } }], + ] as const) { writeFileSync(transcript, event("session.start", { sessionId: id }) - + event(type, { content: [{ type: "text", text: "unsupported native content" }] })); + + event(type, data)); await page.locator(".chat-terminal-fallback summary").waitFor(); assert.equal(await log.getByText("Conversation recovered without switching lenses.", { exact: true }).count(), 0); writeFileSync(transcript, readableHistory); diff --git a/server/copilot-store.ts b/server/copilot-store.ts index f5a76894f7..ea96a1f988 100644 --- a/server/copilot-store.ts +++ b/server/copilot-store.ts @@ -7,11 +7,13 @@ const SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12 const HEADER_BYTES = 64 * 1024; const MAX_DESCRIPTORS = 4096; const LOG_WINDOW_BYTES = 16 * 1024 * 1024; +const LOG_HASH_BYTES = 64 * 1024 * 1024; const LOG_NAME = /^process-\d+-(\d+)\.log$/; -const FOREGROUND_EVENT = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+Z \[INFO\] (Registering|Unregistering) foreground session: ([\da-f-]{36})$/; +const FOREGROUND_PREFIXES = ["Registering foreground session: ", "Unregistering foreground session: "] as const; +const FOREGROUND_EVENT = new RegExp(String.raw`^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+Z \[INFO\] (${FOREGROUND_PREFIXES.join("|")})([\da-f-]{36})$`); interface ForegroundBinding { sessionId: string; generation: string } interface ForegroundCheckpoint { - dev: number; ino: number; path: string; size: number; mtimeMs: number; ctimeMs: number; + dev: bigint; ino: bigint; path: string; size: number; observedSize: number; mtimeNs: bigint; ctimeNs: bigint; pendingSwitch: boolean; hash: Hash; epoch: string; binding: ForegroundBinding | null | undefined; } const foregroundCheckpoints = new Map(); @@ -88,23 +90,30 @@ function loggedForeground(pid: number, logDirectories: readonly string[]): Foreg if (realpathSync(target) !== target) return unavailable(); const fd = openSync(`/proc/${pid}/fd/${descriptor.name}`, constants.O_RDONLY | constants.O_NONBLOCK); try { - const stat = fstatSync(fd); - const named = statSync(target); + const stat = fstatSync(fd, { bigint: true }); + const named = statSync(target, { bigint: true }); if (!stat.isFile() || stat.dev !== named.dev || stat.ino !== named.ino) return unavailable(); + if (stat.size > LOG_HASH_BYTES) return unavailable(); + const size = Number(stat.size); const cached = foregroundCheckpoints.get(pid); - const prefix = cached && stat.size >= cached.size ? logPrefix(fd, cached.size) : null; + if (cached?.dev === stat.dev && cached.ino === stat.ino && cached.path === target + && cached.observedSize === size && cached.mtimeNs === stat.mtimeNs && cached.ctimeNs === stat.ctimeNs) { + found = cached.pendingSwitch ? undefined : cached.binding; + continue; + } + const prefix = cached && size >= cached.size ? logPrefix(fd, cached.size) : null; const same = cached?.dev === stat.dev && cached.ino === stat.ino && cached.path === target - && stat.size >= cached.size && stat.size - cached.size <= LOG_WINDOW_BYTES - && (stat.size !== cached.size || (stat.mtimeMs === cached.mtimeMs && stat.ctimeMs === cached.ctimeMs)) + && size >= cached.size && size - cached.size <= LOG_WINDOW_BYTES + && (size !== cached.size || (stat.mtimeNs === cached.mtimeNs && stat.ctimeNs === cached.ctimeNs)) && prefix?.copy().digest("hex") === cached.hash.copy().digest("hex"); const epoch = same ? cached.epoch : randomUUID(); - const start = same ? cached.size : Math.max(0, stat.size - LOG_WINDOW_BYTES); - const bytes = Buffer.alloc(stat.size - start); + const start = same ? cached.size : Math.max(0, size - LOG_WINDOW_BYTES); + const bytes = Buffer.alloc(size - start); const read = readSync(fd, bytes, 0, bytes.length, start); const hash = same ? prefix : logPrefix(fd, start); - const after = fstatSync(fd); + const after = fstatSync(fd, { bigint: true }); if (read !== bytes.length || hash === null || after.size !== stat.size - || after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs) return unavailable(); + || after.mtimeNs !== stat.mtimeNs || after.ctimeNs !== stat.ctimeNs) return unavailable(); const text = bytes.subarray(0, read).toString("utf8"); const skip = start === 0 || same ? 0 : text.indexOf("\n") + 1; const complete = text.slice(skip, text.lastIndexOf("\n") + 1); @@ -116,27 +125,24 @@ function loggedForeground(pid: number, logDirectories: readonly string[]): Foreg const match = FOREGROUND_EVENT.exec(line); if (match) { if (!SESSION_ID.test(match[2]!)) return unavailable(); - if (match[1] === "Registering") selected = { sessionId: match[2]!, generation: `${pid}:${stat.dev}:${stat.ino}:${epoch}:${position}` }; + if (match[1] === FOREGROUND_PREFIXES[0]) selected = { sessionId: match[2]!, generation: `${pid}:${stat.dev}:${stat.ino}:${epoch}:${position}` }; else selected = undefined; } position += Buffer.byteLength(line) + 1; } const completeBytes = bytes.lastIndexOf(0x0a) + 1; hash.update(bytes.subarray(0, completeBytes)); + const tail = text.slice(text.lastIndexOf("\n") + 1); + // Cache the visible result separately from the last complete binding: + // an unchanged partial timestamp or switch must remain unavailable. + const message = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+Z \[INFO\] (.*)$/.exec(tail)?.[1]; + const pendingSwitch = !!tail && (selected == null || (/^\d/.test(tail) && (message === undefined + || FOREGROUND_PREFIXES.some((event) => event.startsWith(message) || message.startsWith(event))))); foregroundCheckpoints.set(pid, { dev: stat.dev, ino: stat.ino, path: target, size: start + completeBytes, - mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs, hash, epoch, binding: selected, + observedSize: size, mtimeNs: stat.mtimeNs, ctimeNs: stat.ctimeNs, pendingSwitch, hash, epoch, binding: selected, }); - const tail = text.slice(text.lastIndexOf("\n") + 1); - if (tail) { - // A timestamp can still become a lifecycle event. Keep the complete - // checkpoint, but serve it only once the tail rules out a switch. - const message = /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+Z \[INFO\] (.*)$/.exec(tail)?.[1]; - const switchPrefixes = ["Registering foreground session: ", "Unregistering foreground session: "]; - if (selected == null || (/^\d/.test(tail) && (message === undefined - || switchPrefixes.some((event) => event.startsWith(message) || message.startsWith(event))))) return undefined; - } - found = selected; + found = pendingSwitch ? undefined : selected; } finally { closeSync(fd); } } catch { return unavailable(); } } diff --git a/server/copilot.contract.test.ts b/server/copilot.contract.test.ts index 907b1dae74..99939f4166 100644 --- a/server/copilot.contract.test.ts +++ b/server/copilot.contract.test.ts @@ -109,6 +109,8 @@ it.skipIf(process.platform !== "linux")("binds only the reported UUID to the for for (const record of [ event("user.message", { content: [{ type: "text", text: "unsupported prompt" }] }), event("assistant.message", { content: [{ type: "text", text: "unsupported answer" }] }), + event("assistant.message", { toolRequests: ["unreadable request"] }), + event("assistant.message", { content: "", toolRequests: { toolCallId: "call", name: "shell" } }), "malformed complete record\n", ]) { writeFileSync(file(id), header + record); diff --git a/server/copilot.test.ts b/server/copilot.test.ts index 7c27bad58a..0fc860bcd4 100644 --- a/server/copilot.test.ts +++ b/server/copilot.test.ts @@ -1,5 +1,6 @@ import { expect, it, spyOn } from "bun:test"; import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import * as fs from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { parseCopilotTranscript } from "./copilot.ts"; @@ -488,17 +489,25 @@ for (const scenario of ["rewrite", "gap"] as const) { it.skipIf(process.platform !== "linux")(`recovers foreground discovery after a log ${scenario}`, async () => { const fixture = await logFixture([]); const { log, binding } = fixture; + let modifiedAt = Date.now(); + const rewrite = (content: string) => { + writeFileSync(log, content); + // Same-size writes can share an inode timestamp tick. Make each changed + // stat explicit: unchanged identity and stat deliberately reuse evidence. + modifiedAt += 1000; + fs.utimesSync(log, modifiedAt / 1000, modifiedAt / 1000); + }; try { appendFileSync(log, register(session)); const original = binding(); expect(original?.sessionId).toBe(session); if (scenario === "rewrite") { - writeFileSync(log, register(other)); + rewrite(register(other)); expect(binding()?.sessionId).toBe(other); - writeFileSync(log, register(session)); + rewrite(register(session)); expect(binding()?.sessionId).toBe(session); expect(binding()?.generation).not.toBe(original?.generation); - writeFileSync(log, register(other) + "unrelated\n"); + rewrite(register(other) + "unrelated\n"); expect(binding()?.sessionId).toBe(other); const replaced = binding(); appendFileSync(log, "ordinary append\n"); @@ -530,6 +539,69 @@ it.skipIf(process.platform !== "linux")("detects a growing mid-log rewrite with } finally { await fixture.close(); } }); +it.skipIf(process.platform !== "linux")("reuses unchanged lifecycle log checks without reading the prefix again", async () => { + const fixture = await logFixture([]); + const { log, binding } = fixture; + const reads = spyOn(fs, "readSync"); + try { + appendFileSync(log, register(session)); + const original = binding(); + expect(original?.sessionId).toBe(session); + reads.mockClear(); + expect(binding()?.generation).toBe(original?.generation); + expect(reads.mock.calls).toHaveLength(0); + for (const tail of ["ordinary partial line", register(other).trimEnd()]) { + appendFileSync(log, tail); + const partial = binding(); + expect(partial?.sessionId).toBe(tail.startsWith("ordinary") ? session : undefined); + reads.mockClear(); + expect(binding()?.generation).toBe(partial?.generation); + expect(reads.mock.calls).toHaveLength(0); + appendFileSync(log, "\n"); + } + expect(binding()?.sessionId).toBe(other); + } finally { reads.mockRestore(); await fixture.close(); } +}); + +it.skipIf(process.platform !== "linux")("refuses lifecycle logs past the hash cap without reading them or falling back to a held marker", async () => { + const fixture = await logFixture([session]); + const { log, binding, environment, pid, root } = fixture; + const reads = spyOn(fs, "readSync"); + try { + const path = join(root, "session-state", session, "events.jsonl"); + writeFileSync(path, start + prompt("old session")); + appendFileSync(log, register(session)); + expect(binding()?.sessionId).toBe(session); + const fd = fs.openSync(log, "r+"); + try { fs.ftruncateSync(fd, 64 * 1024 * 1024); } finally { fs.closeSync(fd); } + appendFileSync(log, "\n" + register(other)); + reads.mockClear(); + expect(binding()).toBeNull(); + expect(copilotTranscriptFile(session, environment, ["copilot"], pid)).toBeNull(); + expect(reads.mock.calls).toHaveLength(0); + writeFileSync(log, register(other)); + expect(binding()?.sessionId).toBe(other); + } finally { reads.mockRestore(); await fixture.close(); } +}); + +it.each([ + { toolRequests: ["unreadable request"] }, + { content: "", toolRequests: { toolCallId: "call", name: "shell" } }, + { content: "readable prose", toolRequests: [null] }, + { toolRequests: [{}] }, + { toolRequests: [{ toolCallId: 123, name: "shell" }] }, + { toolRequests: [{ toolCallId: "../invalid", name: "shell" }] }, + { toolRequests: [{ toolCallId: "call", name: 123 }] }, + { toolRequests: [{ toolCallId: "call", name: "shell", arguments: [] }] }, +])("rejects unsupported tool request containers and entries: %j", (data) => { + const dir = mkdtempSync(join(tmpdir(), "copilot-unreadable-tool-")); + const path = join(dir, "events.jsonl"); + try { + writeFileSync(path, start + line("assistant.message", data)); + expect(() => transcriptPage("copilot-transcript", path)).toThrow(ConversationUnavailable); + } finally { forgetTranscriptState(); rmSync(dir, { recursive: true, force: true }); } +}); + it.each([ line("user.message", { content: [{ type: "text", text: "unsupported" }] }), line("assistant.message", { content: [{ type: "text", text: "unsupported" }] }), diff --git a/server/copilot.ts b/server/copilot.ts index cfdf5ba691..1473ab0d11 100644 --- a/server/copilot.ts +++ b/server/copilot.ts @@ -96,7 +96,8 @@ export function parseCopilotTranscript(jsonl: string, maxTurns = MAX_TURNS): Con } } else if (type === "assistant.message") { if ((data.content != null && typeof data.content !== "string") - || (data.content == null && !Array.isArray(data.toolRequests))) throw new ConversationUnavailable("transcript_unreadable"); + || (data.content == null && !Array.isArray(data.toolRequests)) + || (data.toolRequests !== undefined && !Array.isArray(data.toolRequests))) throw new ConversationUnavailable("transcript_unreadable"); const content = text(data.content); const requests = Array.isArray(data.toolRequests) ? data.toolRequests : []; if (content === null && requests.length === 0) continue; @@ -108,7 +109,12 @@ export function parseCopilotTranscript(jsonl: string, maxTurns = MAX_TURNS): Con }); for (const raw of requests) { const request = row(raw); - if (request) addTool(request, timestamp, text(request.name) ?? "tool", text(request.intentionSummary)); + const name = text(request?.name); + if (request === null || typeof request.toolCallId !== "string" || !CALL_ID.test(request.toolCallId) + || name === null || (request.arguments != null && row(request.arguments) === null)) { + throw new ConversationUnavailable("transcript_unreadable"); + } + addTool(request, timestamp, name, text(request.intentionSummary)); } if (data.phase === "final_answer") finished = true; } else if (type === "tool.execution_start") { From 0d50becbb8ce0706f78f307819e0546a3c1374da Mon Sep 17 00:00:00 2001 From: devswha Date: Mon, 26 Oct 2026 02:00:09 +0900 Subject: [PATCH 24/25] fix(copilot): recheck racily clean lifecycle logs --- docs/copilot-chat.md | 4 +++- server/copilot-store.ts | 12 +++++++--- server/copilot.test.ts | 50 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/docs/copilot-chat.md b/docs/copilot-chat.md index 5353b37068..e9253f18c1 100644 --- a/docs/copilot-chat.md +++ b/docs/copilot-chat.md @@ -45,7 +45,9 @@ links. The marker-and-report fallback cannot detect an unobserved switch away an The reader scans at most 16 MiB of lifecycle-log catch-up per lookup and checkpoints complete reads, including unavailable selections. Ambiguity remains unavailable until a fresh registration; it does not permanently block later registrations. -Unchanged file identity, size and timestamps reuse the last verification. Changed logs +Unchanged file identity, size and timestamps reuse the last verification only when both +mtime and ctime were at least two seconds old at that check; younger timestamps are rehashed +because a same-size rewrite may share their filesystem tick. Changed logs revalidate the entire consumed prefix in 64 KiB chunks, including bytes outside the catch-up window; logs over 64 MiB use Terminal without reading or hashing them. An incomplete trailing line keeps the last complete diff --git a/server/copilot-store.ts b/server/copilot-store.ts index ea96a1f988..95a0f8faec 100644 --- a/server/copilot-store.ts +++ b/server/copilot-store.ts @@ -8,12 +8,13 @@ const HEADER_BYTES = 64 * 1024; const MAX_DESCRIPTORS = 4096; const LOG_WINDOW_BYTES = 16 * 1024 * 1024; const LOG_HASH_BYTES = 64 * 1024 * 1024; +const LOG_TIMESTAMP_TICK_NS = 2_000_000_000n; const LOG_NAME = /^process-\d+-(\d+)\.log$/; const FOREGROUND_PREFIXES = ["Registering foreground session: ", "Unregistering foreground session: "] as const; const FOREGROUND_EVENT = new RegExp(String.raw`^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+Z \[INFO\] (${FOREGROUND_PREFIXES.join("|")})([\da-f-]{36})$`); interface ForegroundBinding { sessionId: string; generation: string } interface ForegroundCheckpoint { - dev: bigint; ino: bigint; path: string; size: number; observedSize: number; mtimeNs: bigint; ctimeNs: bigint; pendingSwitch: boolean; + dev: bigint; ino: bigint; path: string; size: number; observedSize: number; mtimeNs: bigint; ctimeNs: bigint; checkedAtNs: bigint; pendingSwitch: boolean; hash: Hash; epoch: string; binding: ForegroundBinding | null | undefined; } const foregroundCheckpoints = new Map(); @@ -90,14 +91,19 @@ function loggedForeground(pid: number, logDirectories: readonly string[]): Foreg if (realpathSync(target) !== target) return unavailable(); const fd = openSync(`/proc/${pid}/fd/${descriptor.name}`, constants.O_RDONLY | constants.O_NONBLOCK); try { + const checkedAtNs = BigInt(Date.now()) * 1_000_000n; const stat = fstatSync(fd, { bigint: true }); const named = statSync(target, { bigint: true }); if (!stat.isFile() || stat.dev !== named.dev || stat.ino !== named.ino) return unavailable(); if (stat.size > LOG_HASH_BYTES) return unavailable(); const size = Number(stat.size); const cached = foregroundCheckpoints.get(pid); + // A same-size rewrite can share both timestamps with this check. + // Only a check already outside that tick can attest unchanged bytes. if (cached?.dev === stat.dev && cached.ino === stat.ino && cached.path === target - && cached.observedSize === size && cached.mtimeNs === stat.mtimeNs && cached.ctimeNs === stat.ctimeNs) { + && cached.observedSize === size && cached.mtimeNs === stat.mtimeNs && cached.ctimeNs === stat.ctimeNs + && cached.checkedAtNs - stat.mtimeNs >= LOG_TIMESTAMP_TICK_NS + && cached.checkedAtNs - stat.ctimeNs >= LOG_TIMESTAMP_TICK_NS) { found = cached.pendingSwitch ? undefined : cached.binding; continue; } @@ -140,7 +146,7 @@ function loggedForeground(pid: number, logDirectories: readonly string[]): Foreg || FOREGROUND_PREFIXES.some((event) => event.startsWith(message) || message.startsWith(event))))); foregroundCheckpoints.set(pid, { dev: stat.dev, ino: stat.ino, path: target, size: start + completeBytes, - observedSize: size, mtimeNs: stat.mtimeNs, ctimeNs: stat.ctimeNs, pendingSwitch, hash, epoch, binding: selected, + observedSize: size, mtimeNs: stat.mtimeNs, ctimeNs: stat.ctimeNs, checkedAtNs, pendingSwitch, hash, epoch, binding: selected, }); found = pendingSwitch ? undefined : selected; } finally { closeSync(fd); } diff --git a/server/copilot.test.ts b/server/copilot.test.ts index 0fc860bcd4..9dadea5bcd 100644 --- a/server/copilot.test.ts +++ b/server/copilot.test.ts @@ -543,8 +543,14 @@ it.skipIf(process.platform !== "linux")("reuses unchanged lifecycle log checks w const fixture = await logFixture([]); const { log, binding } = fixture; const reads = spyOn(fs, "readSync"); + const clock = spyOn(Date, "now"); + const ageLog = () => { + const stat = fs.statSync(log); + clock.mockReturnValue(Math.ceil(Math.max(stat.mtimeMs, stat.ctimeMs)) + 5000); + }; try { appendFileSync(log, register(session)); + ageLog(); const original = binding(); expect(original?.sessionId).toBe(session); reads.mockClear(); @@ -552,6 +558,7 @@ it.skipIf(process.platform !== "linux")("reuses unchanged lifecycle log checks w expect(reads.mock.calls).toHaveLength(0); for (const tail of ["ordinary partial line", register(other).trimEnd()]) { appendFileSync(log, tail); + ageLog(); const partial = binding(); expect(partial?.sessionId).toBe(tail.startsWith("ordinary") ? session : undefined); reads.mockClear(); @@ -560,9 +567,50 @@ it.skipIf(process.platform !== "linux")("reuses unchanged lifecycle log checks w appendFileSync(log, "\n"); } expect(binding()?.sessionId).toBe(other); - } finally { reads.mockRestore(); await fixture.close(); } + } finally { clock.mockRestore(); reads.mockRestore(); await fixture.close(); } }); +for (const recent of ["mtime", "ctime", "both"] as const) { + it.skipIf(process.platform !== "linux")(`rechecks an immediate same-size rewrite with racy ${recent} without utimes`, async () => { + const fixture = await logFixture([]); + const { log, binding } = fixture; + const now = Date.now(); + const clock = spyOn(Date, "now").mockReturnValue(now); + const identity = fs.statSync(log, { bigint: true }); + const realFstat = fs.fstatSync; + const mtime = recent === "ctime" ? now - 5000 : now; + const ctime = recent === "mtime" ? now - 5000 : now; + // These are real same-size writes. Model a filesystem that reports their + // mtime and ctime in the same tick, rather than relying on write timing luck. + function tickStat(fd: number, options?: fs.StatOptions & { bigint?: false }): fs.Stats; + function tickStat(fd: number, options: fs.StatOptions & { bigint: true }): fs.BigIntStats; + function tickStat(fd: number, options?: fs.StatOptions): fs.Stats | fs.BigIntStats; + function tickStat(fd: number, options?: fs.StatOptions): fs.Stats | fs.BigIntStats { + const stat = realFstat(fd, options); + if (stat.dev === identity.dev && stat.ino === identity.ino) { + Object.assign(stat, { + mtimeNs: BigInt(mtime) * 1_000_000n, ctimeNs: BigInt(ctime) * 1_000_000n, + mtimeMs: BigInt(mtime), ctimeMs: BigInt(ctime), mtime: new Date(mtime), ctime: new Date(ctime), + }); + } + return stat; + } + const stats = spyOn(fs, "fstatSync").mockImplementation(tickStat); + try { + writeFileSync(log, register(session)); + const original = binding(); + expect(original?.sessionId).toBe(session); + writeFileSync(log, register(other)); + // A later request cannot make the earlier, racily clean check trustworthy. + clock.mockReturnValue(now + 5000); + const replacement = binding(); + expect(replacement?.sessionId).toBe(other); + expect(replacement?.generation).not.toBe(original?.generation); + for (let request = 0; request < 100; request += 1) expect(binding()?.sessionId).toBe(other); + } finally { stats.mockRestore(); clock.mockRestore(); await fixture.close(); } + }); +} + it.skipIf(process.platform !== "linux")("refuses lifecycle logs past the hash cap without reading them or falling back to a held marker", async () => { const fixture = await logFixture([session]); const { log, binding, environment, pid, root } = fixture; From 7121af7204d3cba435a186a40e907d0731d880e7 Mon Sep 17 00:00:00 2001 From: devswha Date: Mon, 26 Oct 2026 02:22:02 +0900 Subject: [PATCH 25/25] fix(copilot): widen timestamp margin and recheck clock rollback --- docs/copilot-chat.md | 6 +++- server/copilot-store.ts | 8 +++-- server/copilot.test.ts | 68 +++++++++++++++++++++++++++++------------ 3 files changed, 58 insertions(+), 24 deletions(-) diff --git a/docs/copilot-chat.md b/docs/copilot-chat.md index e9253f18c1..6b36b56614 100644 --- a/docs/copilot-chat.md +++ b/docs/copilot-chat.md @@ -46,10 +46,14 @@ The reader scans at most 16 MiB of lifecycle-log catch-up per lookup and checkpo complete reads, including unavailable selections. Ambiguity remains unavailable until a fresh registration; it does not permanently block later registrations. Unchanged file identity, size and timestamps reuse the last verification only when both -mtime and ctime were at least two seconds old at that check; younger timestamps are rehashed +mtime and ctime were at least three seconds old at that check and the wall clock has not +moved behind it; younger timestamps or a clock rollback trigger rehashing because a same-size rewrite may share their filesystem tick. Changed logs revalidate the entire consumed prefix in 64 KiB chunks, including bytes outside the catch-up window; logs over 64 MiB use Terminal without reading or hashing them. +This assumes filesystem timestamps trail this machine's clock by no more than that margin. +A filesystem with greater timestamp lag, such as a skewed network mount, can retain a stale +binding until the next append. An incomplete trailing line keeps the last complete binding only when its prefix rules out a lifecycle event. A partial timestamp or possible foreground switch temporarily blocks Chat until the line is complete. diff --git a/server/copilot-store.ts b/server/copilot-store.ts index 95a0f8faec..a45f49930b 100644 --- a/server/copilot-store.ts +++ b/server/copilot-store.ts @@ -8,7 +8,8 @@ const HEADER_BYTES = 64 * 1024; const MAX_DESCRIPTORS = 4096; const LOG_WINDOW_BYTES = 16 * 1024 * 1024; const LOG_HASH_BYTES = 64 * 1024 * 1024; -const LOG_TIMESTAMP_TICK_NS = 2_000_000_000n; +// Cover FAT's two-second granularity plus coarse wall-clock lag. +const LOG_TIMESTAMP_MARGIN_NS = 3_000_000_000n; const LOG_NAME = /^process-\d+-(\d+)\.log$/; const FOREGROUND_PREFIXES = ["Registering foreground session: ", "Unregistering foreground session: "] as const; const FOREGROUND_EVENT = new RegExp(String.raw`^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+Z \[INFO\] (${FOREGROUND_PREFIXES.join("|")})([\da-f-]{36})$`); @@ -102,8 +103,9 @@ function loggedForeground(pid: number, logDirectories: readonly string[]): Foreg // Only a check already outside that tick can attest unchanged bytes. if (cached?.dev === stat.dev && cached.ino === stat.ino && cached.path === target && cached.observedSize === size && cached.mtimeNs === stat.mtimeNs && cached.ctimeNs === stat.ctimeNs - && cached.checkedAtNs - stat.mtimeNs >= LOG_TIMESTAMP_TICK_NS - && cached.checkedAtNs - stat.ctimeNs >= LOG_TIMESTAMP_TICK_NS) { + && checkedAtNs >= cached.checkedAtNs + && cached.checkedAtNs - stat.mtimeNs >= LOG_TIMESTAMP_MARGIN_NS + && cached.checkedAtNs - stat.ctimeNs >= LOG_TIMESTAMP_MARGIN_NS) { found = cached.pendingSwitch ? undefined : cached.binding; continue; } diff --git a/server/copilot.test.ts b/server/copilot.test.ts index 9dadea5bcd..93e37f3d98 100644 --- a/server/copilot.test.ts +++ b/server/copilot.test.ts @@ -556,6 +556,12 @@ it.skipIf(process.platform !== "linux")("reuses unchanged lifecycle log checks w reads.mockClear(); expect(binding()?.generation).toBe(original?.generation); expect(reads.mock.calls).toHaveLength(0); + clock.mockReturnValue(Date.now() - 1000); + expect(binding()?.sessionId).toBe(session); + expect(reads.mock.calls.length).toBeGreaterThan(0); + reads.mockClear(); + expect(binding()?.sessionId).toBe(session); + expect(reads.mock.calls).toHaveLength(0); for (const tail of ["ordinary partial line", register(other).trimEnd()]) { appendFileSync(log, tail); ageLog(); @@ -570,32 +576,54 @@ it.skipIf(process.platform !== "linux")("reuses unchanged lifecycle log checks w } finally { clock.mockRestore(); reads.mockRestore(); await fixture.close(); } }); +/** Only the owned log's timestamp reporting changes; reads, size and identity stay real. */ +function fixedLogTimestamps(log: string, mtime: number, ctime = mtime) { + const identity = fs.statSync(log, { bigint: true }); + const realFstat = fs.fstatSync; + function tickStat(fd: number, options?: fs.StatOptions & { bigint?: false }): fs.Stats; + function tickStat(fd: number, options: fs.StatOptions & { bigint: true }): fs.BigIntStats; + function tickStat(fd: number, options?: fs.StatOptions): fs.Stats | fs.BigIntStats; + function tickStat(fd: number, options?: fs.StatOptions): fs.Stats | fs.BigIntStats { + const stat = realFstat(fd, options); + if (stat.dev === identity.dev && stat.ino === identity.ino) { + Object.assign(stat, { + mtimeNs: BigInt(mtime) * 1_000_000n, ctimeNs: BigInt(ctime) * 1_000_000n, + mtimeMs: BigInt(mtime), ctimeMs: BigInt(ctime), mtime: new Date(mtime), ctime: new Date(ctime), + }); + } + return stat; + } + return spyOn(fs, "fstatSync").mockImplementation(tickStat); +} + +for (const age of [2000, 3000]) { + it.skipIf(process.platform !== "linux")(`checks the unchanged-log timestamp boundary at exactly ${age}ms`, async () => { + const fixture = await logFixture([]); + const { log, binding } = fixture; + const now = Date.now(); + const clock = spyOn(Date, "now").mockReturnValue(now); + const stats = fixedLogTimestamps(log, now - age); + const reads = spyOn(fs, "readSync"); + try { + writeFileSync(log, register(session)); + const original = binding(); + expect(original?.sessionId).toBe(session); + reads.mockClear(); + expect(binding()?.generation).toBe(original?.generation); + if (age === 2000) expect(reads.mock.calls.length).toBeGreaterThan(0); + else expect(reads.mock.calls).toHaveLength(0); + } finally { reads.mockRestore(); stats.mockRestore(); clock.mockRestore(); await fixture.close(); } + }); +} + for (const recent of ["mtime", "ctime", "both"] as const) { it.skipIf(process.platform !== "linux")(`rechecks an immediate same-size rewrite with racy ${recent} without utimes`, async () => { const fixture = await logFixture([]); const { log, binding } = fixture; const now = Date.now(); const clock = spyOn(Date, "now").mockReturnValue(now); - const identity = fs.statSync(log, { bigint: true }); - const realFstat = fs.fstatSync; - const mtime = recent === "ctime" ? now - 5000 : now; - const ctime = recent === "mtime" ? now - 5000 : now; - // These are real same-size writes. Model a filesystem that reports their - // mtime and ctime in the same tick, rather than relying on write timing luck. - function tickStat(fd: number, options?: fs.StatOptions & { bigint?: false }): fs.Stats; - function tickStat(fd: number, options: fs.StatOptions & { bigint: true }): fs.BigIntStats; - function tickStat(fd: number, options?: fs.StatOptions): fs.Stats | fs.BigIntStats; - function tickStat(fd: number, options?: fs.StatOptions): fs.Stats | fs.BigIntStats { - const stat = realFstat(fd, options); - if (stat.dev === identity.dev && stat.ino === identity.ino) { - Object.assign(stat, { - mtimeNs: BigInt(mtime) * 1_000_000n, ctimeNs: BigInt(ctime) * 1_000_000n, - mtimeMs: BigInt(mtime), ctimeMs: BigInt(ctime), mtime: new Date(mtime), ctime: new Date(ctime), - }); - } - return stat; - } - const stats = spyOn(fs, "fstatSync").mockImplementation(tickStat); + // Real same-size writes share a deterministic timestamp tick. + const stats = fixedLogTimestamps(log, recent === "ctime" ? now - 5000 : now, recent === "mtime" ? now - 5000 : now); try { writeFileSync(log, register(session)); const original = binding();