diff --git a/DESIGN.md b/DESIGN.md index e8f8ad3700..c1132f7201 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1876,6 +1876,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/changelog.d/copilot-native-chat.md b/changelog.d/copilot-native-chat.md new file mode 100644 index 0000000000..05cf99aba6 --- /dev/null +++ b/changelog.d/copilot-native-chat.md @@ -0,0 +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. + 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..6b36b56614 --- /dev/null +++ b/docs/copilot-chat.md @@ -0,0 +1,85 @@ +# 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. +Unchanged file identity, size and timestamps reuse the last verification only when both +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. +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. 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 + +```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 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/docs/development.md b/docs/development.md index 06ef4d81e2..5124287cd5 100644 --- a/docs/development.md +++ b/docs/development.md @@ -149,7 +149,8 @@ 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. +`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/docs/guide.md b/docs/guide.md index 983f4828a9..1d09915249 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -167,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 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/ci-browser.sh b/scripts/ci-browser.sh index e7b1b6f622..6810efd15e 100644 --- a/scripts/ci-browser.sh +++ b/scripts/ci-browser.sh @@ -86,6 +86,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/plan-chip-demo-regression.ts diff --git a/scripts/ci-browser.test.ts b/scripts/ci-browser.test.ts index 92476d2cb8..1888c627a1 100644 --- a/scripts/ci-browser.test.ts +++ b/scripts/ci-browser.test.ts @@ -77,6 +77,7 @@ describe("browser lane", () => { const { text, code, evidence } = await lane(); expect(code).toBe(0); expect(rows(text).map((row) => row.script)).toEqual(["scripts/demo-build.ts", ...serialBrowserScripts()]); + expect(rows(text).map((row) => row.script)).toContain("scripts/copilot-chat-browser-qa.ts"); expect(rows(text).every((row) => row.code === 0 && row.seconds >= 0)).toBe(true); expect(evidence).toBe(false); expect(text).not.toMatch(/EVIDENCE=.+/); diff --git a/scripts/copilot-chat-browser-qa.ts b/scripts/copilot-chat-browser-qa.ts new file mode 100644 index 0000000000..46f673f501 --- /dev/null +++ b/scripts/copilot-chat-browser-qa.ts @@ -0,0 +1,186 @@ +/** 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, paneProcessInfo, paneRead, sessionSnapshot, workspaceClose, workspaceCreate } from "../server/herdr/client.ts"; +import { UsageService } from "../server/usage.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" }, + { 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. + 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 }); +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: ${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", + agent_session_id: id, seq: Date.now() * 1000 }); + 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"); + + 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("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").first(); + if (await head.getAttribute("aria-expanded") !== "true") await 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) { + 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); + } + 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); + const readableHistory = readFileSync(transcript, "utf8"); + 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, 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); + 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, transient recovery and unsupported-content terminal fallback"); +} 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 63d091f892..27d79e3fc1 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; @@ -41,6 +42,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-shared.ts b/server/conversation-shared.ts index 6d38aa8968..ed8601971f 100644 --- a/server/conversation-shared.ts +++ b/server/conversation-shared.ts @@ -37,7 +37,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) */ diff --git a/server/conversation.ts b/server/conversation.ts index 1c2c9eec91..200dd07ac7 100644 --- a/server/conversation.ts +++ b/server/conversation.ts @@ -35,11 +35,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, 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, MAX_IMAGE_BYTES } from "./codex-images.ts"; @@ -58,6 +58,8 @@ import { AgyUnavailable, agyCatalog, agyToolOutput, forgetAgyState, parseAgyTran import { resolveAgyStore, sameAgyStore, type AgyStore } from "./agy-store.ts"; import { withinRetentionBudget } from "./retention-budget.ts"; import { forgetAllPiIndexes, forgetPiIndex, piAbandonedTurns, piBranchRecords, piBranchSegments } from "./pi-tree.ts"; +import { copilotOutput, copilotRootRecord, parseCopilotTranscript } from "./copilot.ts"; +import { 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"; @@ -96,6 +98,41 @@ const writtenSessions = new Set(); /** Re-parse on file changes, including replacement and same-size rewrites. */ const cache = new Map(); +/** A restart may parse the same files differently: its answers never match an earlier ETag. */ +const PROCESS_VERSION = randomUUID(); +const copilotBindings = new Map(); + +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), + })), + }; +} + /** * A transcript as one byte stream: for a paginated Codex rollout the history it * continues comes first (codexHistorySegments). Transcripts only grow at the end, @@ -202,11 +239,12 @@ function readStream(stream: TranscriptStream, from: number, to: number): Buffer /** 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 }>(); /** a Claude record that holds a picture, up to the largest one the chat shows, is followed whole when it crosses a window */ const CLAUDE_CARRY_BYTES = Math.ceil(MAX_IMAGE_BYTES * 4 / 3) + 1024 * 1024; function applyHistoryBoundary(path: string, stream: TranscriptStream, source: RecognizedConversation["source"]): void { @@ -231,10 +269,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) { @@ -434,6 +486,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"); @@ -551,6 +604,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"); } @@ -623,6 +678,7 @@ export function forgetTranscriptState(): void { transcriptRevisions.clear(); clearScans.clear(); paneReads.clear(); + copilotBindings.clear(); forgetAllCodexState(); forgetAllPiIndexes(); forgetGrokState(); @@ -641,6 +697,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; @@ -756,7 +813,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; // the omo process that holds a session wins over an omo-looking one listed before it (omo's bundled // Claude SDK binary under /omo-ai/, a helper script): typing follows the held one @@ -934,9 +991,71 @@ async function ompTranscriptPath(paneId: string, cwd: string): Promise { return held; } +/** A process-owned lifecycle registration, or a trusted report plus a unique held marker. */ +async function copilotTranscriptPath(pane: HerdrPane, panes: readonly HerdrPane[], previousClaims?: ReadonlySet): Promise<{ path: string; binding: string; peerClaims: ReadonlySet }> { + const report = pane.agent_session; + // A request shares its peer snapshot across the read; the pane's own process + // and lifecycle evidence are still validated afresh before returning history. + let peerClaims: Promise> | undefined = previousClaims === undefined ? undefined : Promise.resolve(previousClaims); + const gatherPeerClaims = async (): Promise> => { + const claims = new Set(); + 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 = copilotProcessSession(processEnviron(processes[0].pid), processes[0].argv ?? [], processes[0].pid); + if (live?.logged) { + claims.add(live.sessionId); + continue; + } + } + if (peer.agent_session?.agent === "copilot" && peer.agent_session.kind === "id" && typeof peer.agent_session.value === "string") claims.add(peer.agent_session.value); + } + return claims; + }; + const claimedByPeer = async (session: string): Promise => (await (peerClaims ??= gatherPeerClaims())).has(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)))) { + 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 environment = processEnviron(foreground.pid); + const argv = foreground.argv ?? []; + 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, selected); + if (path !== null && nodePath.basename(nodePath.dirname(path)) === logged.sessionId) + 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}`, peerClaims: await peerClaims! }; + } + } + 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); + } + 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. */ type ResolvedTranscript = - | { source: StreamSource; path: string; codexHome?: string; agy?: AgyStore; piAgentDir?: string | null } + | ({ path: string; codexHome?: string; agy?: AgyStore; piAgentDir?: string | null } & ( + | { source: Exclude } + | { source: "copilot-transcript"; binding: string; peerClaims: ReadonlySet } + )) | { source: "opencode-transcript"; path: string; session: string } | { source: "hermes-transcript"; path: string; sessionId: string }; @@ -976,6 +1095,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", ...await copilotTranscriptPath(pane, panes ?? [pane]) }; if (agent === "grok") { const found = await resolveGrokStore(pane, grokHome); if (!found) throw new ConversationUnavailable("no_session_path"); @@ -1128,6 +1248,8 @@ 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(); + 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) { @@ -1174,11 +1296,12 @@ 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); - const answer = transcriptPage(resolved.source, resolved.path, page, resolved.codexHome ?? codexHome, resolved.piAgentDir); + 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, resolved.piAgentDir); const status = snapshot.panes.find((pane) => pane.pane_id === paneId)?.agent_status; if (resolved.source === "grok-transcript" && answer.turns.length === 0 && page.before === undefined && (status === "working" || status === "blocked")) { @@ -1199,6 +1322,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 && resolved.source === "copilot-transcript") { + const pane = snapshot.panes.find((candidate) => candidate.pane_id === paneId)!; + const current = await copilotTranscriptPath(pane, snapshot.panes, resolved.peerClaims); + if (current.path !== resolved.path || current.binding !== resolved.binding) throw new HistoryChanged(); + return bindCopilotAnswer(answer, prefix); + } return answer; } @@ -1291,6 +1420,13 @@ export async function readCommandTranscript(paneId: string, codexHome?: string): * agent directory, whose catalogs state a pi model's context window (parseConversationMetadata). */ export function transcriptPage(source: StreamSource, path: string, page: ConversationPage = {}, codexHome?: string, piAgentDir?: string | null): 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, piAgentDir); } + 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) { @@ -1305,6 +1441,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"); } @@ -1680,7 +1820,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; @@ -1693,8 +1833,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, resolved.peerClaims).catch(() => null); + if (current?.path !== resolved.path || current.binding !== resolved.binding) return null; + } return output; } @@ -1716,6 +1866,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..a45f49930b --- /dev/null +++ b/server/copilot-store.ts @@ -0,0 +1,304 @@ +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, 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; +const HEADER_BYTES = 64 * 1024; +const MAX_DESCRIPTORS = 4096; +const LOG_WINDOW_BYTES = 16 * 1024 * 1024; +const LOG_HASH_BYTES = 64 * 1024 * 1024; +// 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})$`); +interface ForegroundBinding { sessionId: string; generation: string } +interface ForegroundCheckpoint { + 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(); +const foregroundLogProcesses = new Map(); + +/** 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 (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.subarray(0, length)); + } + return hash; +} + +/** 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 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); + } + } + } + // 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); + if (checkpoint) checkpoint.binding = undefined; + return undefined; + }; + const directory = opendirSync(`/proc/${pid}/fd`); + let found: ForegroundBinding | null | undefined = null; + let logFound = false; + let count = 0; + try { + for (let descriptor = directory.readSync(); descriptor; descriptor = directory.readSync()) { + if (++count > MAX_DESCRIPTORS) return unavailable(); + let target: string; + try { target = readlinkSync(`/proc/${pid}/fd/${descriptor.name}`); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + return unavailable(); + } + if (target.endsWith(" (deleted)") && LOG_NAME.test(basename(target.slice(0, -10)))) { + observe(); + return unavailable(); + } + if (!logDirectories.some((path) => dirname(target) === path) || LOG_NAME.exec(basename(target))?.[1] !== String(pid)) continue; + observe(); + if (logFound) return unavailable(); + logFound = true; + try { + 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 + && 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; + } + const prefix = cached && size >= cached.size ? logPrefix(fd, cached.size) : null; + const same = cached?.dev === stat.dev && cached.ino === stat.ino && cached.path === target + && 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, 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, { bigint: true }); + if (read !== bytes.length || hash === null || after.size !== stat.size + || 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); + if (start > 0 && !complete && !same) return unavailable(); + // Only a complete initial log without lifecycle evidence permits marker/report fallback. + let selected = same ? cached.binding : start === 0 && !observedBefore ? 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) { + if (!SESSION_ID.test(match[2]!)) return unavailable(); + 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, + observedSize: size, mtimeNs: stat.mtimeNs, ctimeNs: stat.ctimeNs, checkedAtNs, pendingSwitch, hash, epoch, binding: selected, + }); + found = pendingSwitch ? undefined : selected; + } finally { closeSync(fd); } + } catch { return unavailable(); } + } + } finally { directory.closeSync(); } + return !logFound && owner.observed ? unavailable() : found; +} + +/** 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; +} + +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"); + 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 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.sessionId); + if (realpathSync(directory) !== directory) return null; + return { ...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"), logged: false }; + } catch { return null; } +} + +/** 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); + try { + 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(held: ProcessSession | null, argv: readonly string[]): { sessionId: string; path: string } | null { + 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 { 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 new file mode 100644 index 0000000000..99939f4166 --- /dev/null +++ b/server/copilot.contract.test.ts @@ -0,0 +1,302 @@ +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; + +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 }); + 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"); }; +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) { + 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) => { + 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.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 === ":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"); + } +}); +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 startFixture(pane, id); + 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: { 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([]); + 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 }); + }; + 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" }] }), + event("assistant.message", { toolRequests: ["unreadable request"] }), + event("assistant.message", { content: "", toolRequests: { toolCallId: "call", name: "shell" } }), + "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); + 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 = (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) } })); + 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"); + 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 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"); + 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 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)}`; + 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(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; + if (Date.now() > deadline) throw new Error("isolated fixture did not exit"); + 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); + 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"); +}, 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"; + 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) + 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} `.repeat(600) } })); + } + const created = await workspaceCreate({ cwd: root, label: "herdr-web-ui-test-copilot-lifecycle" }); + const current = created.root_pane.pane_id; + try { + 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; + }>; + 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; + 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"); + } 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); + } + 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); + 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); + } +}, 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"; + 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 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); + } finally { + await workspaceClose(created.workspace.workspace_id); + } +}); diff --git a/server/copilot.test.ts b/server/copilot.test.ts new file mode 100644 index 0000000000..93e37f3d98 --- /dev/null +++ b/server/copilot.test.ts @@ -0,0 +1,785 @@ +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"; +import { copilotProcessSession, copilotTranscriptFile as selectedTranscriptFile, copilotUnwrittenSession as selectedUnwrittenSession, heldSessions } from "./copilot-store.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"; +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") + + 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("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"); + 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.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(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.") + + 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") + + 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("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"; + 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"); + 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, '{"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" }); + 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("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"); + 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(); + 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(); + 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(); + 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); + } finally { + if (stop) await stop(); + rmSync(root, { recursive: true, force: true }); + } +}); + +it.skipIf(process.platform !== "linux")("keeps report-and-marker fallback for a complete CLI log without lifecycle events", async () => { + const fixture = await logFixture([session]); + const { root, log, pid, environment, binding } = fixture; + const path = join(root, "session-state", session, "events.jsonl"); + try { + 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(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, register(session, "Unregistering")); + expect(copilotTranscriptFile(session, environment, argv, pid)).toBeNull(); + writeFileSync(log, "x".repeat(16 * 1024 * 1024) + "\n"); + expect(copilotTranscriptFile(session, environment, argv, pid)).toBeNull(); + writeFileSync(log, "2026-10-23T00:00:00.000Z [INFO] CLI started"); + 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, 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 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") { + rewrite(register(other)); + expect(binding()?.sessionId).toBe(other); + rewrite(register(session)); + expect(binding()?.sessionId).toBe(session); + expect(binding()?.generation).not.toBe(original?.generation); + rewrite(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 { await fixture.close(); } + }); +} + +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.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"); + 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(); + 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(); + 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 { 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); + // 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(); + 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; + 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" }] }), + 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; + try { + 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, register(session)); + expect(binding()?.sessionId).toBe(session); + appendFileSync(log, register(session, "Unregistering")); + expect(binding()).toBeNull(); + appendFileSync(log, register(other)); + expect(binding()?.sessionId).toBe(other); + } finally { await fixture.close(); } +}); + +it.skipIf(process.platform !== "linux")("keeps an initially incomplete log unavailable after its descriptor closes", async () => { + const fixture = await logFixture([session]); + const { root, log, environment, pid } = fixture; + const path = join(root, "session-state", session, "events.jsonl"); + try { + writeFileSync(path, start + prompt("existing conversation")); + const read = () => copilotTranscriptFile(session, environment, ["copilot"], pid); + writeFileSync(log, register(session, "Unregistering").trimEnd()); + expect(read()).toBeNull(); + await fixture.command("close"); + expect(read()).toBeNull(); + await fixture.command("open"); + writeFileSync(log, "ordinary complete line\n"); + expect(read()).toBeNull(); + expect(read()).toBeNull(); + appendFileSync(log, register(session)); + expect(read()).toBe(path); + } finally { await fixture.close(); } +}); + +it.skipIf(process.platform !== "linux")("follows process-owned foreground evidence through fork and switch-back with multiple holds", async () => { + const fixture = await logFixture([session, other]); + const { root, log, environment: options, pid, binding } = fixture; + const args = ["copilot", "--resume", session]; + try { + 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(binding()?.sessionId).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(binding()?.sessionId).toBe(session); + appendFileSync(log, "\n"); + 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 new file mode 100644 index 0000000000..1473ab0d11 --- /dev/null +++ b/server/copilot.ts @@ -0,0 +1,203 @@ +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; +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, strict = false): { type: string; data: Row; timestamp: string | null }[] { + const records: { type: string; data: Row; timestamp: string | null }[] = []; + 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 { + // 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") { + if (strict) throw new ConversationUnavailable("transcript_unreadable"); + continue; + } + if (!copilotRootRecord(entry)) continue; + const data = row(entry.data); + 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; +} + +/** 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>(); + const completionTools = new Map(); + let finished = false; + let completion: string | null = null; + const flushCompletion = (): void => { + const last = turns.at(-1); + const lastText = last?.parts.filter((part) => part.kind === "text").at(-1); + 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; + }; + 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; + }; + 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, true)) { + if (type === "user.message") { + if (typeof data.content !== "string") throw new ConversationUnavailable("transcript_unreadable"); + 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") { + if ((data.content != null && typeof data.content !== "string") + || (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; + 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 } : {}), + }); + for (const raw of requests) { + const request = row(raw); + 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") { + 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; + if (!part || typeof id !== "string") continue; + 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); +} + +/** + * 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), 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; + 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; + }; + 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; + 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; + } + } + // The limit can cut a record: never interpret that prefix as a complete one. + return null; +} 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"} diff --git a/server/transcript-turns.ts b/server/transcript-turns.ts index 9c4b1de331..aacfe8ffd0 100644 --- a/server/transcript-turns.ts +++ b/server/transcript-turns.ts @@ -2,6 +2,7 @@ import type { StreamSource } from "./conversation-shared.ts"; import { claudePeerNotice, piMessage } from "./transcript-records.ts"; import type { TranscriptEntry } from "./claude-transcript.ts"; import { claudeImages, isCommandEntry, withoutPictureBytes } from "./claude-transcript.ts"; +import { copilotRootRecord } from "./copilot.ts"; /** * A Claude session's branch, followed as the file is scanned. Claude Code links each record to the @@ -86,6 +87,7 @@ export function followClaudeBranch(branch: ClaudeBranch, line: Buffer, start: nu /** 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"'), @@ -104,9 +106,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; uuid?: unknown; isMeta?: unknown; isCompactSummary?: unknown; payload?: { type?: unknown }; message?: { role?: unknown; content?: unknown } }; + let entry: { type?: unknown; uuid?: unknown; data?: { content?: 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); diff --git a/shared/protocol.ts b/shared/protocol.ts index 57cf62af2b..d3e1350ec9 100644 --- a/shared/protocol.ts +++ b/shared/protocol.ts @@ -451,7 +451,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; /** diff --git a/src/components/ChatView.tsx b/src/components/ChatView.tsx index 215db6882a..f08a7b31dc 100644 --- a/src/components/ChatView.tsx +++ b/src/components/ChatView.tsx @@ -1364,7 +1364,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 && (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 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[] = [];