diff --git a/src/agent/renderer.ts b/src/agent/renderer.ts deleted file mode 100644 index 9759ae158..000000000 --- a/src/agent/renderer.ts +++ /dev/null @@ -1,327 +0,0 @@ -import type { ReactorEmittedEvent } from "@intx/inference"; -import type { LastCycleSource, TokenUsage } from "@intx/types/runtime"; - -import { formatSessionCostCopy } from "../cost/cost-summary.js"; -import { formatCost } from "../cost/faremeter.js"; -import type { PricingCache } from "../cost/pricing-fetcher.js"; -import { - billingIdentityFromSource, - createSessionCostAccumulator, - type TurnBillingIdentity, -} from "../cost/session-cost.js"; -import { - inferenceErrorMessage, - terminalProviderFailureMessage, -} from "../inference-error-message.js"; - -export interface Renderer { - render(event: ReactorEmittedEvent): void; -} - -const DIM = "\x1b[2m"; -const AMBER = "\x1b[38;5;214m"; -const GREEN = "\x1b[32m"; -const RED = "\x1b[31m"; -const RESET = "\x1b[0m"; - -const SILENT_TOOLS = new Set(["read_file", "list_dir", "search_files", "grep"]); - -function verb(label: string): string { - return `${DIM}${label.padStart(6)}${RESET} `; -} - -function miniDiff(oldStr: string, newStr: string): string { - const oldLines = oldStr.split("\n"); - const newLines = newStr.split("\n"); - const lines: string[] = []; - - // Simple: show removed lines then added lines with 1-line context from old - for (const line of oldLines) { - lines.push(` ${RED}-${RESET} ${line}`); - } - for (const line of newLines) { - lines.push(` ${GREEN}+${RESET} ${line}`); - } - - if (lines.length > 10) { - const kept = lines.slice(0, 10); - kept.push(` ${DIM}... ${lines.length - 10} more${RESET}`); - return kept.join("\n"); - } - return lines.join("\n"); -} - -function formatOp(name: string): string { - if (SILENT_TOOLS.has(name)) { - return name === "read_file" - ? "reading" - : name === "list_dir" - ? "listing" - : name === "search_files" - ? "searching" - : "grepping"; - } - if (name === "run_shell") return "running"; - if (name === "write_file") return "writing"; - if (name === "edit_file") return "editing"; - if (name === "submit_output") return "submitting"; - return name; -} - -function tokenUsageFromEvent( - data: Record | undefined, -): TokenUsage | null { - const usage = data?.usage; - if (usage === null || typeof usage !== "object") return null; - const fields = usage as Record; - if (typeof fields.input !== "number" || typeof fields.output !== "number") - return null; - return { - input: fields.input, - output: fields.output, - cacheRead: typeof fields.cacheRead === "number" ? fields.cacheRead : 0, - cacheWrite: typeof fields.cacheWrite === "number" ? fields.cacheWrite : 0, - thinking: typeof fields.thinking === "number" ? fields.thinking : 0, - }; -} - -function billingIdentityFromEvent( - data: Record | undefined, - fallbackModelId: string, -): TurnBillingIdentity { - const source = data?.source; - if (source === null || typeof source !== "object") { - return { modelId: fallbackModelId }; - } - const fields = source as Record; - if (typeof fields.sourceId !== "string" || typeof fields.model !== "string") { - return { modelId: fallbackModelId }; - } - return billingIdentityFromSource(fields as LastCycleSource); -} - -export function createRenderer( - startedAt: number, - modelId?: string, - pricingCache?: PricingCache | null, -): Renderer { - let currentOp = ""; - let currentArg = ""; - let turnCount = 0; - const pendingArgs = new Map>(); - const pendingNames = new Map(); - let pendingSubmitSummary: string | undefined; - const sessionCost = createSessionCostAccumulator({ - pricingCache: () => pricingCache ?? null, - }); - - function elapsedSecs(): number { - return Math.floor((Date.now() - startedAt) / 1000); - } - - function costText(): string { - const billed = sessionCost.snapshot(); - return formatSessionCostCopy({ - mix: billed.mix, - formattedCost: formatCost(billed.meteredCost), - sessionHiddenReason: billed.hiddenReason, - }); - } - - function writeStatusBar(): void { - const opText = - currentOp.length > 0 - ? `${AMBER}${currentOp}${currentArg ? " " + currentArg : ""}${RESET}` - : ""; - const bar = `${DIM}interchange · turn ${turnCount} · ${costText()} · ${RESET}${opText}${DIM} · ${elapsedSecs()}s${RESET}\r`; - process.stderr.write(bar); - } - - function writeWriteBlock(path: string, content: string): void { - const lineCount = content.split("\n").length; - const delta = `${GREEN}+${lineCount}${RESET}`; - process.stdout.write( - `${verb("write")}${path}${DIM}${" ".repeat(Math.max(1, 44 - path.length))}${RESET}${delta}\n\n`, - ); - } - - function writeEditBlock(path: string, oldStr: string, newStr: string): void { - const removed = oldStr ? oldStr.split("\n").length : 0; - const added = newStr ? newStr.split("\n").length : 0; - const delta = `${GREEN}+${added}${RESET} ${RED}-${removed}${RESET}`; - const diff = miniDiff(oldStr ?? "", newStr ?? ""); - process.stdout.write( - `${verb("edit")}${path}${DIM}${" ".repeat(Math.max(1, 44 - path.length))}${RESET}${delta}\n${diff}\n\n`, - ); - } - - function writeShellBlock( - command: string, - output: string, - isError: boolean, - ): void { - const status = isError ? `${RED}✗${RESET}` : `${GREEN}✓${RESET}`; - if (isError) { - process.stdout.write( - `${verb("shell")}${command}${DIM}${" ".repeat(Math.max(1, 44 - command.length))}${RESET}${status}\n ${output}\n\n`, - ); - } else { - process.stdout.write( - `${verb("shell")}${command}${DIM}${" ".repeat(Math.max(1, 44 - command.length))}${RESET}${status}\n\n`, - ); - } - } - - function writeDoneBlock(summary: string): void { - process.stdout.write(`${verb("done")}${GREEN}${summary}${RESET}\n\n`); - } - - function writeErrorBlock(message: string): void { - process.stdout.write(`${verb("error")}${RED}${message}${RESET}\n\n`); - } - - function render(event: ReactorEmittedEvent): void { - const e = event as { - type: string; - seq?: number; - data?: Record; - }; - - switch (e.type) { - case "inference.tool_call.start": { - const name = String(e.data?.name ?? ""); - currentOp = formatOp(name); - currentArg = - name === "read_file" || - name === "list_dir" || - name === "search_files" || - name === "grep" - ? String((e.data as Record).callId ?? "") - : ""; - break; - } - - case "inference.tool_call.end": { - const callId = String(e.data?.callId ?? ""); - const name = String(e.data?.name ?? ""); - const args = (e.data?.arguments ?? {}) as Record; - pendingArgs.set(callId, args); - pendingNames.set(callId, name); - - if (name === "submit_output") { - pendingSubmitSummary = String(args.summary ?? ""); - } - break; - } - - case "inference.done": { - turnCount++; - currentOp = ""; - currentArg = ""; - const usage = tokenUsageFromEvent(e.data); - if (usage !== null) { - sessionCost.addTurn( - usage, - billingIdentityFromEvent(e.data, modelId ?? ""), - ); - } - break; - } - - case "tool.start": { - const callName = String( - (e.data?.call as Record)?.name ?? "", - ); - currentOp = formatOp(callName); - currentArg = ""; - break; - } - - case "tool.done": { - const result = e.data?.result as Record; - const callId = String(result?.callId ?? ""); - const isError = Boolean(result?.isError); - const content = String(result?.content ?? ""); - const name = pendingNames.get(callId); - const args = pendingArgs.get(callId) ?? {}; - - currentOp = ""; - currentArg = ""; - - if (name === undefined || SILENT_TOOLS.has(name)) { - break; - } - - if (name === "write_file" && !isError) { - writeWriteBlock(String(args.path ?? ""), String(args.content ?? "")); - } else if (name === "edit_file" && !isError) { - writeEditBlock( - String(args.path ?? ""), - String(args.old_string ?? ""), - String(args.new_string ?? ""), - ); - } else if (name === "run_shell") { - writeShellBlock(String(args.command ?? ""), content, isError); - } else if (name === "submit_output" && !isError) { - writeDoneBlock(String(args.summary ?? pendingSubmitSummary ?? "")); - } - - pendingArgs.delete(callId); - pendingNames.delete(callId); - break; - } - - case "reactor.done": { - currentOp = "done"; - writeDoneBlock("reactor finished"); - break; - } - - case "inference.error": { - const err = e.data?.error as Record | undefined; - const rawMessage = String( - err?.message ?? e.data?.error ?? "inference error", - ); - const classifiedError = - typeof err?.category === "string" - ? { - category: err.category, - message: rawMessage, - ...(typeof err.statusCode === "number" - ? { statusCode: err.statusCode } - : {}), - ...(err.raw !== undefined ? { raw: err.raw } : {}), - ...(typeof err.providerId === "string" - ? { providerId: err.providerId } - : {}), - } - : undefined; - const message = - classifiedError === undefined - ? rawMessage - : classifiedError.category === "quota_exhausted" - ? inferenceErrorMessage(classifiedError) - : terminalProviderFailureMessage( - classifiedError.providerId ?? "Unknown", - classifiedError, - ); - writeErrorBlock(message); - break; - } - - case "reactor.error": { - writeErrorBlock(String(e.data?.error ?? "reactor error")); - break; - } - - case "connector.reply": { - currentOp = ""; - break; - } - } - - writeStatusBar(); - } - - return { render }; -} diff --git a/src/renderer.test.ts b/src/renderer.test.ts deleted file mode 100644 index e9e50de8c..000000000 --- a/src/renderer.test.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { describe, test, expect } from "bun:test"; -import type { ReactorEmittedEvent } from "@intx/inference"; -import type { LastCycleSource } from "@intx/types/runtime"; - -import { createRenderer } from "./agent/renderer.js"; -import { createFaremeter, formatCost } from "./cost/faremeter.js"; -import type { PricingCache } from "./cost/pricing-fetcher.js"; -import { testPricingCache } from "./cost/pricing-test-fixture.js"; -import { recastAtLiveModel, tokenUsage } from "../testkit/token-usage.js"; - -// Capture stdout/stderr writes during a test -function captureOutput(): { - stdout: string[]; - stderr: string[]; - restore: () => void; -} { - const stdout: string[] = []; - const stderr: string[] = []; - const origOut = process.stdout.write.bind(process.stdout); - const origErr = process.stderr.write.bind(process.stderr); - process.stdout.write = ((s: string) => { - stdout.push(s); - return true; - }) as typeof process.stdout.write; - process.stderr.write = ((s: string) => { - stderr.push(s); - return true; - }) as typeof process.stderr.write; - return { - stdout, - stderr, - restore: () => { - process.stdout.write = origOut; - process.stderr.write = origErr; - }, - }; -} - -function event( - type: string, - data: Record = {}, -): ReactorEmittedEvent { - return { type, seq: 0, data } as unknown as ReactorEmittedEvent; -} - -// One render-capture pass composing captureOutput + createRenderer + one -// render per event. Every render test below funnels through here so the -// capture/create/render/restore sequence lives in exactly one place. -function renderCapture( - events: readonly ReactorEmittedEvent[], - modelId?: string, - pricingCache?: PricingCache | null, -): { stdout: string[]; stderr: string[] } { - const cap = captureOutput(); - const renderer = createRenderer(Date.now(), modelId, pricingCache); - for (const e of events) renderer.render(e); - cap.restore(); - return cap; -} - -describe("renderer — status bar", () => { - test("status bar uses \\r not \\n", () => { - const cap = renderCapture([event("reactor.start")]); - const bar = cap.stderr.join(""); - expect(bar).toContain("\r"); - expect(bar).not.toMatch(/interchange.*\n/); - }); - - test("status bar shows current op colorized for tool_call.start", () => { - const cap = renderCapture([ - event("inference.tool_call.start", { callId: "c1", name: "read_file" }), - ]); - const bar = cap.stderr.join(""); - // the op text is wrapped in an SGR colour escape + reset - expect(bar).toContain("reading"); - expect(bar).toContain("\u001b["); - }); -}); - -describe("renderer — silent tools write no journal block", () => { - test.each([ - [ - "manage_tasks", - { tasks: [{ id: "1", title: "Add function", status: "in_progress" }] }, - ], - ["search_files", { pattern: "foo" }], - ["grep", { pattern: "bar" }], - ["read_file", { path: "src/foo.ts" }], - ["list_dir", { path: "src/" }], - ])("%s tool.done writes nothing to stdout", (name, args) => { - const cap = renderCapture([ - event("inference.tool_call.end", { callId: "c1", name, arguments: args }), - event("tool.done", { result: { callId: "c1", content: "ok" } }), - ]); - expect(cap.stdout.join("")).toBe(""); - }); -}); - -describe("renderer — write_file journal block", () => { - test("write_file tool.done writes a write block with line count to stdout", () => { - const cap = renderCapture([ - event("inference.tool_call.end", { - callId: "c2", - name: "write_file", - arguments: { path: "src/foo.ts", content: "line1\nline2\nline3" }, - }), - event("tool.done", { result: { callId: "c2", content: "ok" } }), - ]); - const out = cap.stdout.join(""); - expect(out).toContain("write"); - expect(out).toContain("src/foo.ts"); - expect(out).toContain("+3"); - }); -}); - -describe("renderer — edit_file journal block", () => { - test("edit_file tool.done writes an edit block with +/- counts to stdout", () => { - const cap = renderCapture([ - event("inference.tool_call.end", { - callId: "c3", - name: "edit_file", - arguments: { - path: "src/bar.ts", - old_string: "line1\nline2", - new_string: "line1\nline2\nline3\nline4", - }, - }), - event("tool.done", { result: { callId: "c3", content: "ok" } }), - ]); - const out = cap.stdout.join(""); - expect(out).toContain("edit"); - expect(out).toContain("src/bar.ts"); - expect(out).toMatch(/\+\d/); - expect(out).toMatch(/-\d/); - }); -}); - -describe("renderer — run_shell journal block", () => { - test.each([ - { - outcome: "success", - result: { callId: "c4", content: "14 passed" }, - expected: ["shell", "bun test", "✓"], - }, - { - outcome: "failure", - result: { callId: "c5", content: "2 failed", isError: true }, - expected: ["✗", "2 failed"], - }, - ])( - "run_shell $outcome writes journal block to stdout", - ({ result, expected }) => { - const cap = renderCapture([ - event("inference.tool_call.end", { - callId: result.callId, - name: "run_shell", - arguments: { command: "bun test" }, - }), - event("tool.done", { result }), - ]); - for (const fragment of expected) { - expect(cap.stdout.join("")).toContain(fragment); - } - }, - ); -}); - -describe("renderer — submit_output / reactor.done journal block", () => { - test("submit_output tool.done writes done block with summary in green", () => { - const cap = renderCapture([ - event("inference.tool_call.end", { - callId: "c6", - name: "submit_output", - arguments: { summary: "Task complete" }, - }), - event("tool.done", { result: { callId: "c6", content: "ok" } }), - ]); - const out = cap.stdout.join(""); - expect(out).toContain("done"); - expect(out).toContain("Task complete"); - const line = out.split("\n").find((l) => l.includes("Task complete")) ?? ""; - expect(line).toContain("\u001b["); - }); - - test("reactor.done writes done block to stdout", () => { - const cap = renderCapture([event("reactor.done")]); - expect(cap.stdout.join("")).toContain("done"); - }); -}); - -describe("renderer — error blocks", () => { - test("inference.error writes an actionable provider diagnostic in red to stdout", () => { - const cap = renderCapture([ - event("inference.error", { - error: { - category: "protocol_mismatch", - message: "\u001b[31mresponse\n shape changed\u001b[0m", - providerId: "openai", - }, - partial: {}, - }), - ]); - const out = cap.stdout.join(""); - expect(out).toContain("error"); - const line = out.split("\n").find((l) => l.includes("openai")) ?? ""; - expect(line).toContain("\u001b["); - expect(line).toContain("protocol_mismatch"); - expect(line).toContain("response shape changed"); - expect(out).not.toContain("\u001b[31mresponse"); - }); - - test("inference.error surfaces Codex usage_limit_reached with reset ETA", () => { - const cap = renderCapture([ - event("inference.error", { - error: { - category: "quota_exhausted", - message: "Too Many Requests", - statusCode: 429, - raw: { - detail: { - error: { - code: "usage_limit_reached", - message: "You have reached your usage limit.", - plan_type: "workspace_member", - resets_in_seconds: 3435, - }, - }, - }, - }, - partial: {}, - }), - ]); - const out = cap.stdout.join(""); - expect(out).toMatch(/usage limit/i); - expect(out).toMatch(/resets in/i); - expect(out).toContain("/model"); - expect(out).not.toContain("Too Many Requests"); - }); - - test("reactor.error writes error block in red to stdout", () => { - const cap = renderCapture([ - event("reactor.error", { error: "fatal crash", fatal: true }), - ]); - const out = cap.stdout.join(""); - expect(out).toContain("error"); - expect(out).toContain("fatal crash"); - }); -}); - -describe("renderer — miniDiff truncation", () => { - test("edit_file with more than 10 lines shows truncation marker", () => { - const longContent = Array.from( - { length: 12 }, - (_, i) => `line ${i + 1}`, - ).join("\n"); - const cap = renderCapture([ - event("inference.tool_call.end", { - callId: "c-trunc", - name: "edit_file", - arguments: { - path: "src/big.ts", - old_string: longContent, - new_string: longContent, - }, - }), - event("tool.done", { result: { callId: "c-trunc", content: "ok" } }), - ]); - expect(cap.stdout.join("")).toContain("more"); - }); -}); - -describe("renderer — formatOp fallback", () => { - test("unknown tool name is used as-is for op display", () => { - const cap = renderCapture([ - event("inference.tool_call.start", { - name: "custom_tool", - callId: "c-custom", - }), - ]); - expect(cap.stderr.join("")).toContain("custom_tool"); - }); -}); - -describe("renderer — inference.done clears op", () => { - test("inference.done resets currentOp in status bar", () => { - const cap = renderCapture([ - event("inference.tool_call.start", { name: "run_shell", callId: "c-x" }), - event("inference.done", { - turn: { role: "assistant", content: [], model: "test", timestamp: 0 }, - usage: { input: 0, output: 0 }, - source: "test", - }), - ]); - // After inference.done the status bar should no longer contain the op name - const lastStderr = cap.stderr[cap.stderr.length - 1] ?? ""; - expect(lastStderr).not.toContain("running"); - }); -}); - -describe("renderer — tool.start updates op", () => { - test("tool.start sets op to the tool name", () => { - const cap = renderCapture([ - event("tool.start", { call: { name: "run_shell", id: "c-ts" } }), - ]); - expect(cap.stderr.join("")).toContain("running"); - }); -}); - -describe("renderer — connector.reply clears op", () => { - test("connector.reply does not write to stdout", () => { - const cap = renderCapture([ - event("connector.reply", { messageId: "msg-1" }), - ]); - expect(cap.stdout.join("")).toBe(""); - }); -}); - -describe("renderer — mixed vs hidden-only session cost", () => { - const pricingCache = testPricingCache; - - const CODEX_USAGE = tokenUsage(100_000, 20_000); - const METERED_USAGE = tokenUsage(1_000, 500); - const CODEX_SOURCE: LastCycleSource = { - sourceId: "codex/default", - provider: "codex-responses", - model: "gpt-5.6-luna", - }; - const METERED_SOURCE: LastCycleSource = { - sourceId: "openai", - provider: "openai", - model: "glm-5.1", - }; - - test("Codex then metered shows the metered portion only, not a live-model recast", () => { - const cap = renderCapture( - [ - event("inference.done", { usage: CODEX_USAGE, source: CODEX_SOURCE }), - event("inference.done", { - usage: METERED_USAGE, - source: METERED_SOURCE, - }), - ], - "glm-5.1", - pricingCache, - ); - - const meteredOnly = createFaremeter({ modelId: "glm-5.1", pricingCache }); - meteredOnly.addUsage(METERED_USAGE); - const bar = cap.stderr[cap.stderr.length - 1] ?? ""; - const recast = recastAtLiveModel("glm-5.1", pricingCache, [ - CODEX_USAGE, - METERED_USAGE, - ]); - - expect(bar).toContain(formatCost(meteredOnly.getTotalCost())); - expect(bar).toContain( - "metered portion only; session mixed billed and hidden usage", - ); - expect(bar).not.toContain("covered by ChatGPT subscription"); - expect(bar).not.toContain(formatCost(recast)); - expect(meteredOnly.getTotalCost()).toBeLessThan(recast); - }); - - test("hidden-only Codex still uses subscription copy", () => { - const cap = renderCapture( - [event("inference.done", { usage: CODEX_USAGE, source: CODEX_SOURCE })], - "gpt-5.6-luna", - pricingCache, - ); - - const bar = cap.stderr[cap.stderr.length - 1] ?? ""; - expect(bar).toContain( - "covered by ChatGPT subscription (not billed per token)", - ); - expect(bar).not.toMatch(/\$\d/); - }); -}); diff --git a/src/session/event-log.test.ts b/src/session/event-log.test.ts deleted file mode 100644 index d6ef8a33b..000000000 --- a/src/session/event-log.test.ts +++ /dev/null @@ -1,439 +0,0 @@ -import { describe, test, expect } from "bun:test"; -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; - -import { isSessionId } from "./index.js"; -import { - EVENT_LOG_FILE, - SESSION_EVENT_LOG_ENV, - createEventStreamWriter, - isSessionEventLogEnabled, - openEventStreamWriter, -} from "./event-log.js"; - -function tempDir(): string { - return fs.mkdtempSync(path.join(os.tmpdir(), "event-stream-")); -} - -function rawLines(dir: string, file: string = EVENT_LOG_FILE): string[] { - const raw = fs.readFileSync(path.join(dir, file), "utf8"); - return raw.split("\n").filter((line) => line.length > 0); -} - -describe("createEventStreamWriter", () => { - test("appends one JSON line per event with generated UUIDv7 id, seq, ts, payload", async () => { - const dir = tempDir(); - const writer = createEventStreamWriter(dir, { - now: () => 1_700_000_000_000, - }); - const first = await writer.append({ - type: "session.started", - payload: { task: "hello" }, - }); - const second = await writer.append({ id: "fixed-id", type: "turn.done" }); - - expect(isSessionId(first.id)).toBe(true); - expect(first).toEqual({ - id: first.id, - type: "session.started", - seq: 0, - ts: 1_700_000_000_000, - payload: { task: "hello" }, - }); - expect(second.id).toBe("fixed-id"); - expect(second.seq).toBe(1); - - const lines = rawLines(dir); - expect(lines).toHaveLength(2); - for (const line of lines) { - expect(() => JSON.parse(line)).not.toThrow(); - expect(line.endsWith("\n")).toBe(false); - } - expect(lines[0]).toBe(JSON.stringify(first)); - }); - - test("preserves append order and assigns seq in order", async () => { - const dir = tempDir(); - const writer = createEventStreamWriter(dir); - const ids = ["a", "b", "c", "d", "e"]; - for (const id of ids) { - await writer.append({ id, type: "event" }); - } - - const records = await writer.readFrom(-1); - expect(records.map((r) => r.id)).toEqual(ids); - expect(records.map((r) => r.seq)).toEqual([0, 1, 2, 3, 4]); - - const lines = rawLines(dir).map( - (line) => JSON.parse(line) as { id: string }, - ); - expect(lines.map((l) => l.id)).toEqual(ids); - }); - - test("keeps parentId linkage resolvable across the stream", async () => { - const dir = tempDir(); - const writer = createEventStreamWriter(dir); - await writer.append({ id: "root", type: "session.started" }); - await writer.append({ id: "turn-1", parentId: "root", type: "turn.done" }); - await writer.append({ - id: "turn-2", - parentId: "turn-1", - type: "turn.done", - }); - - const records = await writer.readFrom(-1); - expect(records.map((r) => r.parentId)).toEqual([ - undefined, - "root", - "turn-1", - ]); - - const byId = new Map(records.map((r) => [r.id, r])); - const chain: string[] = []; - let cursor: string | undefined = "turn-2"; - while (cursor !== undefined) { - const record = byId.get(cursor); - if (record === undefined) throw new Error(`missing event ${cursor}`); - chain.push(record.id); - cursor = record.parentId; - } - expect(chain).toEqual(["turn-2", "turn-1", "root"]); - }); - - test("a reopened writer continues seq and preserves prior events", async () => { - const dir = tempDir(); - const first = createEventStreamWriter(dir); - await first.append({ id: "evt-1", type: "session.started" }); - await first.append({ id: "evt-2", type: "turn.done" }); - await first.flush(); - - const reopened = createEventStreamWriter(dir); - const third = await reopened.append({ id: "evt-3", type: "turn.done" }); - expect(third.seq).toBe(2); - - const records = await reopened.readFrom(-1); - expect(records.map((r) => r.id)).toEqual(["evt-1", "evt-2", "evt-3"]); - expect(records.map((r) => r.seq)).toEqual([0, 1, 2]); - expect(await reopened.latestCursor()).toBe(2); - }); - - test("flush awaits previously appended events", async () => { - const dir = tempDir(); - const writer = createEventStreamWriter(dir); - await writer.append({ id: "a", type: "event" }); - await writer.append({ id: "b", type: "event" }); - await writer.flush(); - expect(rawLines(dir)).toHaveLength(2); - }); - - test("concurrent writers on one dir cannot duplicate seqs or interleave", async () => { - const dir = tempDir(); - const writerA = createEventStreamWriter(dir); - const writerB = createEventStreamWriter(dir); - const events = await Promise.all( - Array.from({ length: 20 }, (_, i) => - (i % 2 === 0 ? writerA : writerB).append({ - id: `evt-${i}`, - type: "event", - }), - ), - ); - - const seqs = events.map((e) => e.seq); - expect(new Set(seqs).size).toBe(seqs.length); - expect(Math.max(...seqs)).toBe(seqs.length - 1); - - const records = await writerA.readFrom(-1); - expect(records).toHaveLength(20); - expect(new Set(records.map((r) => r.id)).size).toBe(20); - expect(records.map((r) => r.seq)).toEqual([...seqs].sort((a, b) => a - b)); - }); - - test("a torn tail on reopen is dropped: reads succeed and append gets a contiguous seq", async () => { - const dir = tempDir(); - const writer = createEventStreamWriter(dir); - await writer.append({ id: "evt-1", type: "session.started" }); - await writer.flush(); - - fs.appendFileSync( - path.join(dir, EVENT_LOG_FILE), - '{"id":"torn","type":"eve', - ); - - const reader = createEventStreamWriter(dir); - expect((await reader.readFrom(-1)).map((r) => r.id)).toEqual(["evt-1"]); - expect(await reader.latestCursor()).toBe(0); - - const reopened = createEventStreamWriter(dir); - const next = await reopened.append({ id: "evt-2", type: "turn.done" }); - expect(next.seq).toBe(1); - - const records = await reopened.readFrom(-1); - expect(records.map((r) => r.id)).toEqual(["evt-1", "evt-2"]); - expect(records.map((r) => r.seq)).toEqual([0, 1]); - }); - - test("a non-ASCII line plus a torn tail stays two parseable records with distinct seqs", async () => { - const dir = tempDir(); - const writer = createEventStreamWriter(dir); - await writer.append({ - id: "evt-1", - type: "session.started", - payload: { text: "é" }, - }); - await writer.flush(); - - fs.appendFileSync( - path.join(dir, EVENT_LOG_FILE), - '{"id":"torn","type":"eve', - ); - - const reopened = createEventStreamWriter(dir); - const next = await reopened.append({ id: "evt-2", type: "turn.done" }); - expect(next.seq).toBe(1); - - const lines = rawLines(dir); - expect(lines).toHaveLength(2); - const parsed = lines.map( - (line) => JSON.parse(line) as { id: string; seq: number }, - ); - expect(parsed.map((record) => record.id)).toEqual(["evt-1", "evt-2"]); - expect(parsed.map((record) => record.seq)).toEqual([0, 1]); - - const records = await reopened.readFrom(-1); - expect(records.map((r) => r.id)).toEqual(["evt-1", "evt-2"]); - expect(records.map((r) => r.seq)).toEqual([0, 1]); - const again = await reopened.append({ id: "evt-3", type: "turn.done" }); - expect(again.seq).toBe(2); - }); - - test("readFrom returns only events past the cursor", async () => { - const dir = tempDir(); - const writer = createEventStreamWriter(dir); - for (const id of ["a", "b", "c", "d"]) { - await writer.append({ id, type: "event" }); - } - const after = await writer.readFrom(1); - expect(after.map((r) => r.id)).toEqual(["c", "d"]); - expect(await writer.readFrom(3)).toEqual([]); - }); - - test("latestCursor reports -1 on an empty log and the last seq after appends", async () => { - const dir = tempDir(); - const writer = createEventStreamWriter(dir); - expect(await writer.latestCursor()).toBe(-1); - await writer.append({ id: "a", type: "event" }); - await writer.append({ id: "b", type: "event" }); - expect(await writer.latestCursor()).toBe(1); - }); - - test("projectTurns returns turn events in order", async () => { - const dir = tempDir(); - const writer = createEventStreamWriter(dir); - await writer.append({ id: "s", type: "session.started" }); - await writer.append({ - id: "t1", - type: "turn.user", - payload: { text: "hi" }, - }); - await writer.append({ - id: "m", - type: "model.selected", - payload: { model: "x" }, - }); - await writer.append({ - id: "t2", - type: "turn.assistant", - payload: { text: "yo" }, - }); - - const turns = (await writer.projectTurns()) as { id: string }[]; - expect(turns.map((t) => t.id)).toEqual(["t1", "t2"]); - }); - - test("projectPrompt joins text payloads in order", async () => { - const dir = tempDir(); - const writer = createEventStreamWriter(dir); - await writer.append({ - id: "a", - type: "turn.user", - payload: { text: "first" }, - }); - await writer.append({ - id: "b", - type: "turn.user", - payload: { text: "second" }, - }); - await writer.append({ id: "c", type: "turn.user" }); - expect(await writer.projectPrompt()).toBe("first\nsecond"); - }); - - test("backfillFromLegacy appends turns.jsonl as chained turn.legacy events", async () => { - const dir = tempDir(); - fs.writeFileSync( - path.join(dir, "turns.jsonl"), - '{"role":"user","text":"one"}\n{"role":"assistant","text":"two"}\n', - ); - const writer = createEventStreamWriter(dir); - expect(await writer.backfillFromLegacy()).toBe(2); - - const records = await writer.readFrom(-1); - expect(records).toHaveLength(2); - expect(records.every((r) => r.type === "turn.legacy")).toBe(true); - const [first, second] = records; - if (first === undefined || second === undefined) { - throw new Error("expected two backfilled events"); - } - expect(first.parentId).toBeUndefined(); - expect(second.parentId).toBe(first.id); - expect(first.payload).toMatchObject({ role: "user", text: "one" }); - }); - - test("backfillFromLegacy returns 0 when turns.jsonl is absent", async () => { - const dir = tempDir(); - const writer = createEventStreamWriter(dir); - expect(await writer.backfillFromLegacy()).toBe(0); - }); - - test("backfillFromLegacy is idempotent across re-runs", async () => { - const dir = tempDir(); - fs.writeFileSync( - path.join(dir, "turns.jsonl"), - '{"role":"user","text":"one"}\n{"role":"assistant","text":"two"}\n', - ); - const writer = createEventStreamWriter(dir); - expect(await writer.backfillFromLegacy()).toBe(2); - expect(await writer.backfillFromLegacy()).toBe(0); - - const records = await writer.readFrom(-1); - expect(records).toHaveLength(2); - expect(records.map((r) => r.type)).toEqual(["turn.legacy", "turn.legacy"]); - }); - - test("backfillFromLegacy skips only already-backfilled lines", async () => { - const dir = tempDir(); - fs.writeFileSync( - path.join(dir, "turns.jsonl"), - '{"role":"user","text":"one"}\n{"role":"assistant","text":"two"}\n', - ); - const writer = createEventStreamWriter(dir); - expect(await writer.backfillFromLegacy()).toBe(2); - - fs.appendFileSync( - path.join(dir, "turns.jsonl"), - '{"role":"user","text":"three"}\n', - ); - expect(await writer.backfillFromLegacy()).toBe(1); - - const records = await writer.readFrom(-1); - expect(records).toHaveLength(3); - }); - - test("backfillFromLegacy keeps identical lines and does not duplicate them on re-run", async () => { - const dir = tempDir(); - const line = '{"role":"user","text":"same"}\n'; - fs.writeFileSync(path.join(dir, "turns.jsonl"), line + line); - const writer = createEventStreamWriter(dir); - expect(await writer.backfillFromLegacy()).toBe(2); - expect(await writer.backfillFromLegacy()).toBe(0); - expect(await writer.readFrom(-1)).toHaveLength(2); - }); - - test("backfillFromLegacy includes rolled turn segments in order", async () => { - const dir = tempDir(); - fs.writeFileSync( - path.join(dir, "turns.jsonl"), - '{"role":"user","text":"one"}\n', - ); - fs.writeFileSync( - path.join(dir, "turns-0001.jsonl"), - '{"role":"user","text":"two"}\n', - ); - const writer = createEventStreamWriter(dir); - expect(await writer.backfillFromLegacy()).toBe(2); - const records = await writer.readFrom(-1); - expect( - records.map((record) => (record.payload as { text?: string }).text), - ).toEqual(["one", "two"]); - }); - - test("backfillFromLegacy stores user text where projectPrompt reads it", async () => { - const dir = tempDir(); - fs.writeFileSync( - path.join(dir, "turns.jsonl"), - '{"role":"user","text":"one"}\n', - ); - const writer = createEventStreamWriter(dir); - expect(await writer.backfillFromLegacy()).toBe(1); - expect(await writer.projectPrompt()).toBe("one"); - }); - - test("flush rejects when the append it covers rejects", async () => { - const dir = tempDir(); - const writer = createEventStreamWriter(dir); - const payload: { self?: unknown } = {}; - payload.self = payload; - const appending = writer.append({ type: "event", payload }); - const flushing = writer.flush(); - const settled = await Promise.allSettled([appending, flushing]); - expect(settled[0]?.status).toBe("rejected"); - expect(settled[1]?.status).toBe("rejected"); - await expect(appending).rejects.toThrow(TypeError); - await expect(flushing).rejects.toThrow(TypeError); - }); -}); - -describe("event stream flag gating", () => { - test("isSessionEventLogEnabled honors settings and env, with explicit off", () => { - expect(isSessionEventLogEnabled({})).toBe(false); - expect( - isSessionEventLogEnabled({ settings: { sessionEventLog: true } }), - ).toBe(true); - expect( - isSessionEventLogEnabled({ settings: { sessionEventLog: false } }), - ).toBe(false); - expect( - isSessionEventLogEnabled({ flagEnv: { [SESSION_EVENT_LOG_ENV]: "1" } }), - ).toBe(true); - expect( - isSessionEventLogEnabled({ - flagEnv: { [SESSION_EVENT_LOG_ENV]: "true" }, - }), - ).toBe(true); - for (const off of ["", "0", "false", "no", "off"]) { - expect( - isSessionEventLogEnabled({ - settings: { sessionEventLog: true }, - flagEnv: { [SESSION_EVENT_LOG_ENV]: off }, - }), - ).toBe(false); - } - }); - - test("openEventStreamWriter returns null (inert) when the flag is off", () => { - const dir = tempDir(); - expect( - openEventStreamWriter(dir, { settings: { sessionEventLog: false } }), - ).toBeNull(); - expect( - openEventStreamWriter(dir, { - flagEnv: { [SESSION_EVENT_LOG_ENV]: "off" }, - }), - ).toBeNull(); - expect(fs.existsSync(path.join(dir, EVENT_LOG_FILE))).toBe(false); - }); - - test("openEventStreamWriter returns a live writer when the flag is on", async () => { - const dir = tempDir(); - const writer = openEventStreamWriter(dir, { - settings: { sessionEventLog: true }, - }); - expect(writer).not.toBeNull(); - if (writer === null) - throw new Error("flag-enabled writer should not be null"); - await writer.append({ type: "session.started" }); - expect(await writer.readFrom(-1)).toHaveLength(1); - expect(fs.existsSync(path.join(dir, EVENT_LOG_FILE))).toBe(true); - }); -}); diff --git a/src/session/event-log.ts b/src/session/event-log.ts deleted file mode 100644 index ea5c2b56d..000000000 --- a/src/session/event-log.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { appendFile, mkdir, readFile, truncate } from "node:fs/promises"; -import { join } from "node:path"; - -import { generateSessionId } from "./index.js"; -import { listSegmentFiles } from "./incremental-jsonl.js"; -import { withResolvedDirLock } from "./session-dir-lock.js"; - -export const EVENT_LOG_FILE = "event-log.jsonl"; - -export const SESSION_EVENT_LOG_ENV = "CORBITS_SESSION_EVENT_LOG"; - -export interface EventStreamRecord { - id: string; - parentId?: string; - type: string; - seq: number; - ts: number; - payload?: unknown; -} - -export interface AppendEventInput { - id?: string; - parentId?: string; - type: string; - payload?: unknown; -} - -export interface EventStreamWriter { - append(event: AppendEventInput): Promise; - flush(): Promise; - readFrom(cursor: number): Promise; - latestCursor(): Promise; - projectTurns(): Promise; - projectPrompt(): Promise; - backfillFromLegacy(): Promise; -} - -export interface EventStreamWriterOptions { - now?: () => number; -} - -export interface OpenEventStreamWriterOptions extends EventStreamWriterOptions { - settings?: { sessionEventLog?: boolean }; - flagEnv?: Record; -} - -export function isSessionEventLogEnabled( - sources: OpenEventStreamWriterOptions = {}, -): boolean { - const raw = (sources.flagEnv ?? process.env)[SESSION_EVENT_LOG_ENV]; - if (raw !== undefined) { - const normalized = raw.trim().toLowerCase(); - return ( - normalized !== "" && - normalized !== "0" && - normalized !== "false" && - normalized !== "no" && - normalized !== "off" - ); - } - return sources.settings?.sessionEventLog === true; -} - -function isENOENT(err: unknown): boolean { - return ( - typeof err === "object" && - err !== null && - "code" in err && - (err as { code?: unknown }).code === "ENOENT" - ); -} - -function hashLine(line: string): string { - let hash = 0x811c9dc5; - for (let i = 0; i < line.length; i++) { - hash ^= line.charCodeAt(i); - hash = Math.imul(hash, 0x01000193); - } - return (hash >>> 0).toString(16).padStart(8, "0"); -} - -// Drop an unterminated tail so a torn append does not shift seq. -function completeLines(raw: string): string[] { - if (raw.length === 0) return []; - const split = raw.split("\n"); - if (!raw.endsWith("\n")) split.pop(); - return split.filter((line) => line.length > 0); -} - -function legacyPayload( - parsed: unknown, - legacyKey: string, -): Record { - if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) { - return { ...(parsed as Record), legacyKey }; - } - return { record: parsed, legacyKey }; -} - -function parseRecordLine(line: string): EventStreamRecord { - return JSON.parse(line) as EventStreamRecord; -} - -async function readRawOrEmpty(filePath: string): Promise { - try { - return await readFile(filePath, "utf8"); - } catch (err) { - if (isENOENT(err)) return ""; - throw err; - } -} - -// Truncate the torn tail on disk so the next append cannot glue onto it. -async function dropTornTail(filePath: string, raw: string): Promise { - if (raw.length === 0 || raw.endsWith("\n")) return raw; - const lastNl = raw.lastIndexOf("\n"); - const kept = raw.slice(0, lastNl + 1); - await truncate(filePath, Buffer.byteLength(kept)); - return kept; -} - -export function createEventStreamWriter( - dir: string, - opts: EventStreamWriterOptions = {}, -): EventStreamWriter { - const now = opts.now ?? Date.now; - const filePath = join(dir, EVENT_LOG_FILE); - - let dirReady: Promise | null = null; - // flush() waits on this writer only; the dir lock orders concurrent writers. - let pending: Promise = Promise.resolve(); - - const ensureDir = async (): Promise => { - if (dirReady === null) { - dirReady = mkdir(dir, { recursive: true }).then(() => undefined); - } - await dirReady; - }; - - // Seq is the on-disk line count, so callers must already hold the dir lock. - const appendLocked = async ( - event: AppendEventInput, - ): Promise => { - await ensureDir(); - const raw = await readRawOrEmpty(filePath); - const cleaned = await dropTornTail(filePath, raw); - const seq = completeLines(cleaned).length; - const record: EventStreamRecord = { - id: event.id ?? generateSessionId(), - type: event.type, - seq, - ts: now(), - }; - if (event.parentId !== undefined) record.parentId = event.parentId; - if (event.payload !== undefined) record.payload = event.payload; - await appendFile(filePath, `${JSON.stringify(record)}\n`, "utf8"); - return record; - }; - - const append = (event: AppendEventInput): Promise => { - const run = withResolvedDirLock(dir, () => appendLocked(event)); - pending = run.then(() => undefined); - void pending.catch(() => undefined); - return run; - }; - - const flush = async (): Promise => { - const current = pending; - try { - await current; - } catch (err) { - if (pending === current) pending = Promise.resolve(); - throw err; - } - }; - - const readAll = async (): Promise => { - const raw = await readRawOrEmpty(filePath); - return completeLines(raw).map((line) => parseRecordLine(line)); - }; - - const readFrom = async (cursor: number): Promise => { - const records = await readAll(); - return records.filter((record) => record.seq > cursor); - }; - - const latestCursor = async (): Promise => { - const records = await readAll(); - const last = records[records.length - 1]; - return last === undefined ? -1 : last.seq; - }; - - const projectTurns = async (): Promise => { - const records = await readAll(); - return records - .filter((record) => record.type.startsWith("turn.")) - .map((record) => ({ ...record, ...(record.payload as object) })); - }; - - const projectPrompt = async (): Promise => { - const records = await readAll(); - const texts: string[] = []; - for (const record of records) { - const payload = record.payload as { text?: unknown } | undefined; - if (typeof payload?.text === "string" && payload.text.length > 0) { - texts.push(payload.text); - } - } - return texts.join("\n"); - }; - - const backfillFromLegacy = (): Promise => - withResolvedDirLock(dir, async () => { - const names = await listSegmentFiles(dir, "turns.jsonl"); - if (names.length === 0) return 0; - const records = await readAll(); - const existingKeys = new Set(); - for (const record of records) { - const payload = record.payload as { legacyKey?: unknown } | undefined; - if (typeof payload?.legacyKey === "string") { - existingKeys.add(payload.legacyKey); - } - } - let lastId = records[records.length - 1]?.id; - let count = 0; - const occurrences = new Map(); - for (const name of names) { - const raw = await readFile(join(dir, name), "utf8"); - for (const line of raw.split("\n")) { - if (line.length === 0) continue; - const hash = hashLine(line); - const index = occurrences.get(hash) ?? 0; - occurrences.set(hash, index + 1); - const key = `${hash}:${index}`; - if (existingKeys.has(key)) continue; - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch { - continue; - } - const event: AppendEventInput = { - type: "turn.legacy", - payload: legacyPayload(parsed, key), - }; - if (lastId !== undefined) event.parentId = lastId; - const appended = await appendLocked(event); - existingKeys.add(key); - lastId = appended.id; - count += 1; - } - } - return count; - }); - - return { - append, - flush, - readFrom, - latestCursor, - projectTurns, - projectPrompt, - backfillFromLegacy, - }; -} - -export function openEventStreamWriter( - dir: string, - opts: OpenEventStreamWriterOptions = {}, -): EventStreamWriter | null { - if (!isSessionEventLogEnabled(opts)) return null; - const writerOpts: EventStreamWriterOptions = {}; - if (opts.now !== undefined) writerOpts.now = opts.now; - return createEventStreamWriter(dir, writerOpts); -} diff --git a/src/session/optimized-context-store.test.ts b/src/session/optimized-context-store.test.ts index 8c78df32c..b5458d327 100644 --- a/src/session/optimized-context-store.test.ts +++ b/src/session/optimized-context-store.test.ts @@ -1,5 +1,4 @@ import { defined } from "../../testkit/defined.js"; -import { withMockedModuleDuring } from "../../testkit/mock-module.js"; import { describe, test, expect } from "bun:test"; import fs from "node:fs"; import os from "node:os"; @@ -594,7 +593,7 @@ describe("createOptimizedContextStore checkpoint", () => { const dir = tempDir(); const store = await createOptimizedContextStore(dir); await store.writeTurns([turn("a")]); - await store.writePrompt([turn("p0")]); + fs.writeFileSync(path.join(dir, "prompt.jsonl"), jsonl([turn("p0")])); await store.writeMetadata(EMPTY_CHECKPOINT_METADATA); const first = await store.commit({ message: "base" }); @@ -1210,43 +1209,6 @@ describe("concurrent sessions write disjoint transcripts", () => { expect(turnTexts((await resumed.load()).turns)).not.toContain("from-B"); }); - test("a prompt is not written to the shared file before ownership is decided", async () => { - const dir = tempDir(); - const promptFile = "prompt.jsonl"; - const sessionA = await createOptimizedContextStore(dir, { - ownerId: "session-A", - }); - await sessionA.writeTurns([turn("a"), turn("b")]); - await sessionA.writePrompt([turn("a"), turn("b"), turn("ephemeral-A")]); - await sessionA.writeMetadata(EMPTY_CHECKPOINT_METADATA); - await sessionA.commit({ message: "session A" }); - const promptPath = path.join(dir, promptFile); - const ownedPrompt = fs.readFileSync(promptPath, "utf8"); - - const sessionB = await createOptimizedContextStore(dir, { - ownerId: "session-B", - }); - await sessionB.writePrompt([turn("foreign-prompt")]); - expect(fs.readFileSync(promptPath, "utf8")).toBe(ownedPrompt); - await sessionB.writeTurns([turn("x"), turn("y")]); - expect(fs.readFileSync(promptPath, "utf8")).toBe(ownedPrompt); - await sessionB.writeMetadata(EMPTY_CHECKPOINT_METADATA); - await sessionB.commit({ message: "session B" }); - - expect(fs.readFileSync(promptPath, "utf8")).toBe(ownedPrompt); - expect(turnTexts(readTurnsFromFile(path.join(dir, TURNS_FILE)))).toEqual([ - "a", - "b", - ]); - const versionedPrompts = fs - .readdirSync(dir) - .filter((name) => /^prompt-\d+-[0-9a-f]+\.jsonl$/.test(name)); - expect(versionedPrompts).toHaveLength(1); - expect( - fs.readFileSync(path.join(dir, defined(versionedPrompts[0])), "utf8"), - ).toContain("foreign-prompt"); - }); - test( "two processes cannot both claim an empty session directory", async () => { @@ -1619,209 +1581,6 @@ describe("createOptimizedContextStore unpublished rewrite", () => { }); }); -describe("createOptimizedContextStore prompt dedupe (CL-9026)", () => { - const PROMPT_FILE = "prompt.jsonl"; - - function toolResultTurn(body: string): ConversationTurn { - return { - role: "user", - content: [ - { - type: "tool_result", - callId: "call-1", - content: [{ type: "text", text: body }], - }, - ], - timestamp: 1, - }; - } - - function cloneTurns(turns: ConversationTurn[]): ConversationTurn[] { - return turns.map((t) => JSON.parse(JSON.stringify(t)) as ConversationTurn); - } - - test("identical writePrompt writes no prompt segment", async () => { - const dir = tempDir(); - const store = await createOptimizedContextStore(dir); - const turns = [turn("a"), turn("b")]; - await store.writeTurns([...turns]); - await store.writePrompt([...turns]); - await store.writePrompt(cloneTurns(turns)); - - expect(fs.existsSync(path.join(dir, PROMPT_FILE))).toBe(false); - expect(await listSegmentFiles(dir, PROMPT_FILE)).toEqual([]); - - await store.writeMetadata(EMPTY_CHECKPOINT_METADATA); - await store.commit({ message: "identical prompt is a no-op" }); - expect(await gitLsTree(dir)).not.toContain(PROMPT_FILE); - - // A fresh instance compares against on-disk live turns and also skips. - const fresh = await createOptimizedContextStore(dir); - await fresh.writePrompt(cloneTurns(turns)); - expect(fs.existsSync(path.join(dir, PROMPT_FILE))).toBe(false); - }); - - test("differing writePrompt still writes the prompt snapshot", async () => { - const dir = tempDir(); - const store = await createOptimizedContextStore(dir); - const live = [turn("a"), toolResultTurn("x".repeat(1000))]; - await store.writeTurns([...live]); - - const shrunk = [turn("a"), toolResultTurn("summarized")]; - await store.writePrompt(shrunk); - expect(fs.existsSync(path.join(dir, PROMPT_FILE))).toBe(true); - expect(fs.readFileSync(path.join(dir, PROMPT_FILE), "utf-8")).toBe( - jsonl(shrunk), - ); - - // An appended ephemeral turn also changes the prompt and still writes. - const ephemeralDir = tempDir(); - const ephemeralStore = await createOptimizedContextStore(ephemeralDir); - await ephemeralStore.writeTurns([...live]); - const withEphemeral = [...cloneTurns(live), turn("ephemeral")]; - await ephemeralStore.writePrompt(withEphemeral); - expect(fs.readFileSync(path.join(ephemeralDir, PROMPT_FILE), "utf-8")).toBe( - jsonl(withEphemeral), - ); - }); - - test("turns-only store loads live turns and commits no prompt file", async () => { - const dir = tempDir(); - const store = await createOptimizedContextStore(dir); - await store.writeTurns([turn("a"), turn("b")]); - await store.writeMetadata(EMPTY_CHECKPOINT_METADATA); - const first = await store.commit({ message: "turns only" }); - - expect(turnTexts((await store.load()).turns)).toEqual(["a", "b"]); - - const fresh = await createOptimizedContextStore(dir); - expect(turnTexts((await fresh.load()).turns)).toEqual(["a", "b"]); - const second = await fresh.commit({ message: "turns-only no-op" }); - expect(second.hash).toBe(first.hash); - expect(fs.existsSync(path.join(dir, PROMPT_FILE))).toBe(false); - expect(await gitLsTree(dir)).not.toContain(PROMPT_FILE); - }); - - test("identical writePrompt after a differing one removes stale prompt segments", async () => { - const dir = tempDir(); - const store = await createOptimizedContextStore(dir); - const live = [turn("a"), turn("b")]; - await store.writeTurns([...live]); - await store.writePrompt([turn("a")]); - expect(fs.existsSync(path.join(dir, PROMPT_FILE))).toBe(true); - - await store.writePrompt(cloneTurns(live)); - expect(fs.existsSync(path.join(dir, PROMPT_FILE))).toBe(false); - expect(await listSegmentFiles(dir, PROMPT_FILE)).toEqual([]); - - await store.writeMetadata(EMPTY_CHECKPOINT_METADATA); - await store.commit({ message: "stale prompt removed" }); - expect(await gitLsTree(dir)).not.toContain(PROMPT_FILE); - expect(turnTexts((await store.load()).turns)).toEqual(["a", "b"]); - }); - - test("committed then identical writePrompt drops prompt.jsonl from HEAD", async () => { - const dir = tempDir(); - const store = await createOptimizedContextStore(dir); - const live = [turn("a"), turn("b")]; - await store.writeTurns([...live]); - await store.writePrompt([turn("a")]); - await store.writeMetadata(EMPTY_CHECKPOINT_METADATA); - await store.commit({ message: "differing prompt" }); - expect(await gitLsTree(dir)).toContain(PROMPT_FILE); - - await store.writePrompt(cloneTurns(live)); - await store.writeMetadata(EMPTY_CHECKPOINT_METADATA); - const afterIdentical = await store.commit({ message: "identical prompt" }); - expect(await gitLsTree(dir)).not.toContain(PROMPT_FILE); - expect(turnTexts((await store.load()).turns)).toEqual(["a", "b"]); - - await store.writeMetadata(EMPTY_CHECKPOINT_METADATA); - const empty = await store.commit({ message: "empty checkpoint" }); - expect(empty.hash).toBe(afterIdentical.hash); - }); - - test("identical writePrompt after multi-segment differing prompt drops all prompt files from HEAD", async () => { - const dir = tempDir(); - const store = await createOptimizedContextStore(dir); - const live: ConversationTurn[] = []; - const big = "x".repeat(20_000); - for (let i = 0; i < 18; i++) { - live.push(turn(`${i}-${big}`)); - } - await store.writeTurns([...live]); - - const differing: ConversationTurn[] = []; - for (let i = 0; i < 18; i++) { - differing.push(turn(`p-${i}-${big}`)); - } - await store.writePrompt(differing); - const extraPrompt = segmentFileName(PROMPT_FILE, 1); - expect(fs.existsSync(path.join(dir, PROMPT_FILE))).toBe(true); - expect(fs.existsSync(path.join(dir, extraPrompt))).toBe(true); - - await store.writeMetadata(EMPTY_CHECKPOINT_METADATA); - await store.commit({ message: "multi-segment prompt" }); - const treeAfterDiffering = await gitLsTree(dir); - expect(treeAfterDiffering).toContain(PROMPT_FILE); - expect(treeAfterDiffering).toContain(extraPrompt); - - await store.writePrompt(cloneTurns(live)); - await store.writeMetadata(EMPTY_CHECKPOINT_METADATA); - await store.commit({ message: "identical drops prompt segments" }); - const tree = await gitLsTree(dir); - expect(tree).not.toContain(PROMPT_FILE); - expect(tree).not.toContain(extraPrompt); - expect(turnTexts((await store.load()).turns)).toEqual(turnTexts(live)); - }); - - test("one remove failure still unstages the other paths in one index write", async () => { - const dir = tempDir(); - const store = await createOptimizedContextStore(dir); - const huge = "x".repeat(300_000); - const live = [turn(`live-${huge}`), turn("live-tail")]; - await store.writeTurns([...live]); - await store.writePrompt([turn(`prompt-${huge}`), turn("prompt-tail")]); - await store.writeMetadata(EMPTY_CHECKPOINT_METADATA); - await store.commit({ message: "two prompt segments" }); - const extraPrompt = segmentFileName(PROMPT_FILE, 1); - expect(await gitLsTree(dir)).toEqual( - expect.arrayContaining([PROMPT_FILE, extraPrompt]), - ); - - await store.writePrompt(cloneTurns(live)); - await store.writeMetadata(EMPTY_CHECKPOINT_METADATA); - - const removeCalls: (string | string[])[] = []; - await withMockedModuleDuring( - import.meta.resolve("isomorphic-git"), - (real: typeof import("isomorphic-git")) => { - const remove: typeof real.default.remove = async (args) => { - removeCalls.push(args.filepath); - const paths = Array.isArray(args.filepath) - ? args.filepath - : [args.filepath]; - if (paths.includes(extraPrompt)) { - throw new Error("index write failed"); - } - return real.default.remove(args); - }; - return { ...real, default: { ...real.default, remove } }; - }, - () => store.commit({ message: "drop prompt segments" }), - ); - - const tree = await gitLsTree(dir); - expect(tree).not.toContain(PROMPT_FILE); - expect(tree).toContain(extraPrompt); - const written = removeCalls.filter((filepath) => { - const paths = Array.isArray(filepath) ? filepath : [filepath]; - return !paths.includes(extraPrompt); - }); - expect(written).toEqual([[PROMPT_FILE]]); - }); -}); - describe("createOptimizedContextStore per-turn perf (CL-10666)", () => { const SEGMENT_MANIFEST_FILE = "segment-manifest.json"; diff --git a/src/session/optimized-context-store.ts b/src/session/optimized-context-store.ts index 0cc038c1b..d051cacd5 100644 --- a/src/session/optimized-context-store.ts +++ b/src/session/optimized-context-store.ts @@ -88,7 +88,7 @@ const ConversationTurnSchema = type({ // Persisted turns are immutable once appended; re-parsed history rebuilds its // memo entries anyway, so this skips re-serializing an unchanged history on -// every comparison (contentPrefixLength, promptEqualsLiveTurns). +// every comparison (contentPrefixLength). const turnJsonMemo = new WeakMap(); function turnJson(turn: ConversationTurn): string { @@ -1162,8 +1162,8 @@ async function publishMetadataDocument( /** * Interchange git-store wrapper that avoids O(session length) work per - * checkpoint: turns and prompts write as rolling segments so only the active - * one re-hashes; `base.commit()` keeps the vendor lock, durable commit, + * checkpoint: turns write as rolling segments so only the active one + * re-hashes; `base.commit()` keeps the vendor lock, durable commit, * signing, and GC. */ export async function createSessionStores( @@ -1177,14 +1177,10 @@ export async function createSessionStores( const ownerToken = opts?.ownerId ?? globalThis.crypto.randomUUID(); const explicitOwnerId = opts?.ownerId; let turnsBaseName = TURNS_FILE; - let promptBaseName = PROMPT_FILE; let versionedBase: string | null = null; let writeTurnsSegmented = createSegmentedJSONLWriter(dir, TURNS_FILE); - let writePromptSegmented = createSegmentedJSONLWriter(dir, PROMPT_FILE); let liveTurnRefs: readonly ConversationTurn[] | null = null; let unpublishedRewrite: ConversationTurn[] | null = null; - let pendingPrompt: ConversationTurn[] | null = null; - let stagedPrompt: ConversationTurn[] | null = null; // metadata.json ownership (CL-10667): this store writes the file itself, so // the base's pendingConnectorState buffer would never land. The written // payload is the compare baseline — metadata.json changes only through this @@ -1249,8 +1245,6 @@ export async function createSessionStores( versionedBase = versionedBaseName(TURNS_FILE); turnsBaseName = versionedBase; writeTurnsSegmented = createSegmentedJSONLWriter(dir, turnsBaseName); - promptBaseName = versionedBaseName(PROMPT_FILE); - writePromptSegmented = createSegmentedJSONLWriter(dir, promptBaseName); } function refPrefixLength( @@ -1474,32 +1468,6 @@ export async function createSessionStores( return "divert"; } - async function queuePrompt( - turns: readonly ConversationTurn[], - ): Promise { - if (versionedBase !== null && liveTurnRefs === null) { - stagedPrompt = [...turns]; - pendingPrompt = null; - return; - } - if ( - liveTurnRefs === null && - unpublishedRewrite === null && - versionedBase === null - ) { - pendingPrompt = [...turns]; - return; - } - await writePromptIfDiffered(turns); - } - - async function flushPendingPrompt(): Promise { - if (pendingPrompt === null) return; - const prompt = pendingPrompt; - pendingPrompt = null; - await writePromptIfDiffered(prompt); - } - async function writeTurnsLiveOrStage( turns: readonly ConversationTurn[], ): Promise { @@ -1536,21 +1504,15 @@ export async function createSessionStores( if (decision === "divert") { divertToVersionedBases(); unpublishedRewrite = [...turns]; - if (pendingPrompt !== null) { - stagedPrompt = pendingPrompt; - pendingPrompt = null; - } return; } if (decision === "rewrite") { unpublishedRewrite = [...turns]; - await flushPendingPrompt(); return; } await writeSegmented(writeTurnsSegmented, turns); liveTurnRefs = [...turns]; invalidateTurnsCache(); - await flushPendingPrompt(); }); } @@ -1582,66 +1544,6 @@ export async function createSessionStores( return [...baseTurns, ...parsedExtras.slice(0, keepExtras).flat()]; } - function promptEqualsLiveTurns( - live: readonly ConversationTurn[], - prompt: readonly ConversationTurn[], - ): boolean { - if (live.length !== prompt.length) return false; - for (let index = 0; index < prompt.length; index++) { - const liveTurn = live[index]; - const promptTurn = prompt[index]; - if (liveTurn === undefined || promptTurn === undefined) return false; - if (liveTurn === promptTurn) continue; - if (turnJson(liveTurn) !== turnJson(promptTurn)) return false; - } - return true; - } - - // The reactor checkpoints the prompt every cycle; identical snapshots - // double disk and re-hash cost for nothing. load() never reads - // prompt.jsonl, so skipping leaves resume unchanged; differing prompts - // still write. - async function writePromptIfDiffered( - turns: readonly ConversationTurn[], - ): Promise { - let live: readonly ConversationTurn[] | null = null; - if (unpublishedRewrite !== null) live = unpublishedRewrite; - else if (liveTurnRefs !== null) live = liveTurnRefs; - else { - // Fresh instance: recover live turns through the shared generation - // cache; failure falls through to a normal write so an unreadable - // baseline cannot drop the snapshot. - try { - live = await readLiveTurnsCached(); - } catch { - live = null; - } - } - if (versionedBase !== null && liveTurnRefs === null) { - // No versioned file before commit; a crash would orphan it. - return; - } - if (live === null || !promptEqualsLiveTurns(live, turns)) { - // A differing prompt is normal on resume and must not divert. - await writeSegmented(writePromptSegmented, turns); - return; - } - // Identical: converge disk to no prompt segment so a stale snapshot - // cannot linger; removals join pendingSegmentPaths for commit staging. - const highest = await highestSegmentIndex(dir, promptBaseName); - let removed = false; - for (let index = 0; index <= highest; index++) { - const name = segmentFileName(promptBaseName, index); - if (!(await pathExists(path.join(dir, name)))) continue; - await fs.promises.unlink(path.join(dir, name)); - pendingSegmentPaths.add(name); - removed = true; - } - if (removed) { - writePromptSegmented = createSegmentedJSONLWriter(dir, promptBaseName); - } - } - const store: ContextStore & AuditStore = { // Full-history read: the reactor's initialization contract. Turns come // through the shared generation cache (strict base parse with resilient @@ -1732,9 +1634,10 @@ export async function createSessionStores( return [...baseTurns, ...parsedExtras.slice(0, keepExtras).flat()]; }, readBlob: (key, signal) => base.readBlob(key, signal), - // Skipped identical snapshots fall back to live turns; load() never reads - // prompt.jsonl. - writePrompt: (turns) => queuePrompt(turns), + // load() reads turns.jsonl, never prompt.jsonl. The checkpoint duplicated + // that history and nothing reads it, same fold as writeResponse. Legacy + // trees that still hold one keep staging as a vendor root. + writePrompt: () => Promise.resolve(), // CL-10667 fold: response.jsonl duplicated the assistant turn turns.jsonl // already carries, and nothing in the repo reads it (grep over src, // scripts, e2e, and the vendored packages: write-only). The per-commit @@ -1743,8 +1646,7 @@ export async function createSessionStores( writeResponse: () => Promise.resolve(), async writeManifest(records, signal) { if (records.length === 0) { - // Empty cycle buffers converge the worktree to no manifest, the same - // way an identical prompt converges to no prompt segment: the removal + // Empty cycle buffers converge the worktree to no manifest: the removal // joins the pending set, one index write carries it at commit, and // readManifestHistory reads a missing manifest as no records. if (await pathExists(path.join(dir, MANIFEST_FILE))) { @@ -1766,30 +1668,7 @@ export async function createSessionStores( }, async commit(options, signal) { return withResolvedDirLock(dir, async () => { - if ( - pendingPrompt !== null && - versionedBase === null && - unpublishedRewrite === null && - liveTurnRefs === null - ) { - const marker = await readOwnerMarker(dir); - if ( - marker !== null && - explicitOwnerId !== undefined && - marker.ownerId !== ownerToken - ) { - divertToVersionedBases(); - stagedPrompt = pendingPrompt; - pendingPrompt = null; - } else { - const prompt = pendingPrompt; - pendingPrompt = null; - await writePromptIfDiffered(prompt); - } - } - const stagedRewrite = unpublishedRewrite; - const promptToPublish = stagedPrompt; // The staged rewrite lands on the working-tree segments before the git // operations below; snapshot them so a failed commit can restore the // published generation. The rewrite stays staged so a retry can still @@ -1798,10 +1677,6 @@ export async function createSessionStores( stagedRewrite === null ? null : await snapshotTurnSegments(dir, turnsBaseName); - const promptSnapshot = - promptToPublish === null - ? null - : await snapshotTurnSegments(dir, promptBaseName); // One HEAD resolution per commit. The catch re-resolves on purpose: // it must observe HEAD after the commit to detect a landed commit. let headWithinLock: Promise | undefined; @@ -1809,10 +1684,7 @@ export async function createSessionStores( headWithinLock ??= headOid(dir); return headWithinLock; }; - const headBefore = - stagedRewrite === null && promptToPublish === null - ? null - : await lockedHead(); + const headBefore = stagedRewrite === null ? null : await lockedHead(); let extraPaths: string[] = []; try { @@ -1820,9 +1692,6 @@ export async function createSessionStores( await writeSegmented(writeTurnsSegmented, stagedRewrite); invalidateTurnsCache(); } - if (promptToPublish !== null) { - await writeSegmented(writePromptSegmented, promptToPublish); - } const listing = await surveyWorktree(dir); const toAdd: string[] = []; const toRemove: string[] = []; @@ -1851,7 +1720,7 @@ export async function createSessionStores( ); await reconcileSegmentStaging( dir, - promptBaseName, + PROMPT_FILE, toAdd, toRemove, listing, @@ -1954,11 +1823,10 @@ export async function createSessionStores( liveTurnRefs = stagedRewrite; unpublishedRewrite = null; } - if (promptToPublish !== null) stagedPrompt = null; return committed; } catch (cause) { await resetIndexPaths(dir, extraPaths); - if (segmentSnapshot !== null || promptSnapshot !== null) { + if (segmentSnapshot !== null) { // Restore the published generation unless the commit actually // landed despite throwing (a ref write or post-commit check can // fail after HEAD moved), in which case the on-disk rewrite @@ -1967,47 +1835,22 @@ export async function createSessionStores( const landed = headBefore !== null && headNow !== null && headNow !== headBefore; if (!landed) { - if (segmentSnapshot !== null) { - try { - await restoreTurnSegments( - dir, - segmentSnapshot, - turnsBaseName, - ); - } catch { - // A partial restore must not mask the real commit error. - } - } - if (promptSnapshot !== null) { - try { - await restoreTurnSegments( - dir, - promptSnapshot, - promptBaseName, - ); - } catch { - // A partial restore must not mask the real commit error. - } + try { + await restoreTurnSegments(dir, segmentSnapshot, turnsBaseName); + } catch { + // A partial restore must not mask the real commit error. } } // Drop the writer's stale in-memory state so a retry rewrites from // scratch. - if (segmentSnapshot !== null) { - writeTurnsSegmented = createSegmentedJSONLWriter( - dir, - turnsBaseName, - ); - liveTurnRefs = null; - // The restore rewrote the published generation; the cached - // live-turns parse predates it in general, so rebuild from disk. - invalidateTurnsCache(); - } - if (promptSnapshot !== null) { - writePromptSegmented = createSegmentedJSONLWriter( - dir, - promptBaseName, - ); - } + writeTurnsSegmented = createSegmentedJSONLWriter( + dir, + turnsBaseName, + ); + liveTurnRefs = null; + // The restore rewrote the published generation; the cached + // live-turns parse predates it in general, so rebuild from disk. + invalidateTurnsCache(); } throw cause; } diff --git a/src/tui/observe-live.test.ts b/src/tui/observe-live.test.ts deleted file mode 100644 index 055c37ab8..000000000 --- a/src/tui/observe-live.test.ts +++ /dev/null @@ -1,404 +0,0 @@ -/** - * Live subagent observe: host-pushed rows + parent restore. - * Pure child-event → StreamRow mappers live in observe-map (no renderer). - */ -import { describe, expect, test } from "bun:test"; -import { focusOwner } from "./focus/index.js"; -import { withTestRenderer, type Harness } from "./harness.js"; -import { - mapChildStreamEvent, - mapChildStreamSequence, - rowFromBridgeEvent, - rowsFromBridgeEventsCoalesced, -} from "./observe-map.js"; -import type { ObserveSession } from "./residuals.js"; -import type { StreamRow } from "./stream.js"; -import { createStreamMapContext } from "./stream-event-map.js"; -import { appendObserveStreamRow, appendStreamRow } from "./shell/chrome.js"; -import { createAppShell } from "./shell/index.js"; -import { enterSubagentObserve, leaveSubagentObserve } from "./shell/observe.js"; - -type Shell = ReturnType; - -function liveChildSession( - lines: readonly StreamRow[], - opts?: { readonly agentId?: string; readonly description?: string }, -): ObserveSession { - return { - sessionId: "live-child-1", - agentId: opts?.agentId ?? "explorer", - description: opts?.description ?? "live map callers", - lines, - }; -} - -function withShell( - fn: (shell: Shell, h: Harness) => void | Promise, -): Promise { - return withTestRenderer( - async (h) => { - const shell = createAppShell(h.renderer, { - terminal: { columns: 80, rows: 24 }, - run: "idle", - }); - try { - await fn(shell, h); - } finally { - shell.dispose(); - } - }, - { width: 80, height: 24 }, - ); -} - -describe("live subagent observe", () => { - test("enter accepts host live rows + agent label", async () => { - await withShell((shell) => { - appendStreamRow(shell, { role: "user", text: "parent before" }); - - const liveLines: StreamRow[] = [ - { role: "system", text: "— live child —" }, - { role: "assistant", text: "scanning repo…" }, - { role: "tool", text: "grep openListOverlay", meta: "tool" }, - ]; - enterSubagentObserve( - shell, - liveChildSession(liveLines, { - agentId: "explorer", - description: "map callers", - }), - ); - - expect(shell.observe?.sessionId).toBe("live-child-1"); - expect(shell.observe?.agentId).toBe("explorer"); - expect(shell.observe?.description).toBe("map callers"); - expect(focusOwner(shell.focus)).toBe("observe"); - expect(shell.parentStreamLog).not.toBeNull(); - expect(shell.streamLog.some((r) => r.text === "scanning repo…")).toBe( - true, - ); - expect( - shell.streamLog.some((r) => r.text.includes("Viewing explore")), - ).toBe(true); - // Parent row is not visible while observing. - expect(shell.streamLog.some((r) => r.text === "parent before")).toBe( - false, - ); - expect(shell.layout.heights.agents).toBeGreaterThan(0); - }); - }); - - test("host can append child stream events while observing", async () => { - await withShell((shell) => { - enterSubagentObserve( - shell, - liveChildSession([{ role: "system", text: "seed" }]), - ); - - const ok = appendObserveStreamRow(shell, { - role: "assistant", - text: "live delta from host", - }); - expect(ok).toBe(true); - expect( - shell.streamLog.some((r) => r.text === "live delta from host"), - ).toBe(true); - expect( - shell.observe?.lines.some((r) => r.text === "live delta from host"), - ).toBe(true); - - // Parent appends during observe stay on the snapshot, not the child view. - appendStreamRow(shell, { - role: "assistant", - text: "parent mid-observe", - }); - expect(shell.streamLog.some((r) => r.text === "parent mid-observe")).toBe( - false, - ); - expect( - shell.parentStreamLog?.some((r) => r.text === "parent mid-observe"), - ).toBe(true); - }); - }); - - test("leave restores parent transcript snapshot and focus lease", async () => { - await withShell((shell) => { - appendStreamRow(shell, { role: "user", text: "parent user line" }); - appendStreamRow(shell, { - role: "assistant", - text: "parent assistant line", - }); - const parentLen = shell.streamLog.length; - - enterSubagentObserve( - shell, - liveChildSession([ - { role: "system", text: "child only" }, - { role: "assistant", text: "child work" }, - ]), - ); - appendObserveStreamRow(shell, { - role: "tool", - text: "child tool hit", - meta: "tool.done", - }); - appendStreamRow(shell, { - role: "system", - text: "parent while away", - }); - - leaveSubagentObserve(shell); - - expect(shell.observe).toBeNull(); - expect(shell.parentStreamLog).toBeNull(); - expect(focusOwner(shell.focus)).not.toBe("observe"); - // Parent gained exactly the row appended while away plus the - // "left observe" system row — never a doubled copy of either. - expect(shell.streamLog.length).toBe(parentLen + 2); - expect( - shell.streamLog.filter((r) => r.text === "parent user line").length, - ).toBe(1); - expect( - shell.streamLog.filter((r) => r.text === "parent while away").length, - ).toBe(1); - expect( - shell.streamLog.filter((r) => r.text.includes("left observe")).length, - ).toBe(1); - // Child rows must not leak into the restored parent transcript. - expect(shell.streamLog.some((r) => r.text === "child only")).toBe(false); - expect(shell.streamLog.some((r) => r.text === "child tool hit")).toBe( - false, - ); - }); - }); - - test("leave does not duplicate rows across repeated enter/observe/leave cycles", async () => { - await withShell((shell) => { - appendStreamRow(shell, { role: "user", text: "start" }); - - for (let cycle = 0; cycle < 3; cycle++) { - enterSubagentObserve( - shell, - liveChildSession([{ role: "assistant", text: `cycle ${cycle}` }]), - ); - appendObserveStreamRow(shell, { - role: "tool", - text: `child tool ${cycle}`, - meta: "tool.done", - }); - leaveSubagentObserve(shell); - } - - // One "start" row and one "left observe" row per cycle; no - // child row and no doubled parent row from any cycle. - expect(shell.streamLog.filter((r) => r.text === "start").length).toBe(1); - expect( - shell.streamLog.filter((r) => r.text.includes("left observe")).length, - ).toBe(3); - for (let cycle = 0; cycle < 3; cycle++) { - expect( - shell.streamLog.some((r) => r.text === `child tool ${cycle}`), - ).toBe(false); - } - }); - }); - - test("appendObserveStreamRow is no-op when not observing", async () => { - await withShell((shell) => { - const before = shell.streamLog.length; - const ok = appendObserveStreamRow(shell, { - role: "assistant", - text: "should not land", - }); - expect(ok).toBe(false); - expect(shell.streamLog.length).toBe(before); - }); - }); - - test("Esc key leaves observe and restores parent", async () => { - await withShell(async (shell, h) => { - appendStreamRow(shell, { role: "user", text: "stay" }); - enterSubagentObserve( - shell, - liveChildSession([{ role: "system", text: "child" }]), - ); - expect(shell.observe).not.toBeNull(); - - // ESC needs disambiguation delay on the mock stdin path. - h.pressKey("Escape"); - await new Promise((r) => setTimeout(r, 60)); - await h.renderOnce(); - - expect(shell.observe).toBeNull(); - expect(shell.streamLog.some((r) => r.text === "stay")).toBe(true); - expect(focusOwner(shell.focus)).not.toBe("observe"); - }); - }); - - test("host paints mapped child reactor events into observe view", async () => { - await withShell((shell) => { - const seed = mapChildStreamSequence([ - { - type: "message.received", - data: { message: { content: "child task" } }, - }, - { - type: "connector.reply", - data: { content: "child answer" }, - }, - ]); - enterSubagentObserve(shell, liveChildSession(seed)); - - const ctx = createStreamMapContext(); - for (const row of mapChildStreamEvent( - { - type: "tool.done", - data: { - result: { - name: "grep", - content: "6 hits", - isError: false, - }, - }, - }, - ctx, - )) { - appendObserveStreamRow(shell, row); - } - - expect(shell.streamLog.some((r) => r.text === "child task")).toBe(true); - expect(shell.streamLog.some((r) => r.text === "child answer")).toBe(true); - expect( - shell.streamLog.some((r) => r.role === "tool" && r.text === "6 hits"), - ).toBe(true); - }); - }); -}); - -describe("observe pure mappers", () => { - test("rowFromBridgeEvent covers paint roles", () => { - expect(rowFromBridgeEvent({ type: "user", text: "u" })).toEqual({ - role: "user", - text: "u", - }); - expect(rowFromBridgeEvent({ type: "assistant", text: "a" })).toEqual({ - role: "assistant", - text: "a", - }); - expect( - rowFromBridgeEvent({ type: "tool_call", name: "bash", detail: "ls" }), - ).toEqual({ - role: "tool", - text: "ls", - meta: "bash", - toolName: "bash", - verb: "Bash", - summary: "ls", - pending: true, - callKey: "bash Bash ls", - }); - expect( - rowFromBridgeEvent({ - type: "tool_result", - name: "bash", - detail: "out", - isError: true, - }), - ).toEqual({ - role: "tool", - text: "out", - meta: "bash", - toolName: "bash", - failed: true, - }); - expect(rowFromBridgeEvent({ type: "system", text: "s" })).toEqual({ - role: "system", - text: "s", - }); - expect(rowFromBridgeEvent({ type: "error", message: "e" })).toEqual({ - role: "system", - text: "e", - meta: "error", - }); - expect(rowFromBridgeEvent({ type: "run", state: "busy" })).toBeNull(); - expect(rowFromBridgeEvent({ type: "tool.boundary" })).toBeNull(); - expect( - rowFromBridgeEvent({ type: "assistant.delta", text: "x" }), - ).toBeNull(); - }); - - test("mapChildStreamEvent maps production reactor types", () => { - expect( - mapChildStreamEvent({ - type: "message.received", - data: { message: { content: "go" } }, - }), - ).toEqual([{ role: "system", text: "go" }]); - expect(mapChildStreamEvent({ type: "inference.start" })).toEqual([]); - expect( - mapChildStreamEvent({ - type: "inference.text.delta", - data: { token: "Hi" }, - }), - ).toEqual([]); - }); - - test("rowsFromBridgeEventsCoalesced folds assistant deltas", () => { - expect( - rowsFromBridgeEventsCoalesced([ - { type: "assistant.delta", text: "Hel" }, - { type: "assistant.delta", text: "lo" }, - { type: "tool_call", name: "read_file", detail: "a.ts" }, - { type: "assistant.delta", text: "done" }, - ]), - ).toEqual([ - { role: "assistant", text: "Hello" }, - { - role: "tool", - text: "a.ts", - meta: "read_file", - toolName: "read_file", - verb: "Read", - summary: "a.ts", - pending: true, - callKey: "read_file Read a.ts", - }, - { role: "assistant", text: "done" }, - ]); - }); - - test("mapChildStreamSequence tracks tool names across events", () => { - const rows = mapChildStreamSequence([ - { - type: "inference.tool_call.start", - data: { name: "grep", callId: "c1" }, - }, - { - type: "inference.tool_call.end", - data: { - name: "grep", - callId: "c1", - arguments: { q: "observe" }, - }, - }, - { - type: "tool.done", - data: { - result: { callId: "c1", content: "hits", isError: false }, - }, - }, - ]); - // The call and its answer are one row: the payload replaces the argument - // JSON on the row it opened, and no second row is appended. - expect(rows).toMatchObject([ - { - role: "tool", - text: "hits", - meta: "grep", - verb: "Grep", - summary: "observe", - }, - ]); - expect(rows.length).toBe(1); - }); -}); diff --git a/src/tui/observe-map.ts b/src/tui/observe-map.ts deleted file mode 100644 index 3304696ac..000000000 --- a/src/tui/observe-map.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** - * Pure observe mappers — child stream / bridge events → StreamRow. - * - * Shell owns enter/leave + appendObserveStreamRow; hosts use these helpers to - * turn live child reactor events (or already-mapped bridge events) into rows - * before painting the observe view. No renderer deps. - */ - -import { toolCallRow } from "./diff.js"; -import { toolResultRow } from "./mcp-view.js"; -import { pushToolCall, pushToolResult } from "./tool-rows.js"; -import type { StreamRow } from "./stream.js"; -import { - createStreamMapContext, - mapProductionEvent, - type BridgeInboundEvent, - type ReactorLikeEvent, - type StreamMapContext, -} from "./stream-event-map.js"; - -/** - * Map one canonical bridge inbound event to a paint row. Non-row events (run - * state, tool.boundary, assistant.delta) return null — callers coalesce - * deltas / apply run state themselves when needed. - */ -export function rowFromBridgeEvent( - event: BridgeInboundEvent, -): StreamRow | null { - switch (event.type) { - case "user": - return { role: "user", text: event.text }; - case "assistant": - return { role: "assistant", text: event.text }; - case "tool_call": - return toolCallRow({ - name: event.name, - ...(event.detail !== undefined ? { arguments: event.detail } : {}), - ...(event.callId !== undefined ? { callId: event.callId } : {}), - }); - case "tool_result": - return toolResultRow({ - name: event.name, - content: event.detail ?? (event.isError ? "error" : "ok"), - isError: event.isError === true, - ...(event.callId !== undefined ? { callId: event.callId } : {}), - }); - case "system": - return { role: "system", text: event.text }; - case "error": - return { role: "system", text: event.message, meta: "error" }; - default: - return null; - } -} - -/** - * Map zero or more bridge events to paint rows (filters nulls). Does not - * coalesce assistant.delta. Tool events fold rather than map one-to-one: a - * call and its result are one row, and a repeat collapses onto the row it - * repeats. - */ -export function rowsFromBridgeEvents( - events: readonly BridgeInboundEvent[], -): StreamRow[] { - const rows: StreamRow[] = []; - const attempt: AttemptBoundary = { at: null }; - for (const event of events) { - pushBridgeEvent(rows, event, attempt); - } - return rows; -} - -/** - * Row index where the inference attempt in progress began. A failed attempt - * is re-streamed from scratch, so its rows are retracted, not appended to. - */ -interface AttemptBoundary { - at: number | null; -} - -/** Fold one bridge event onto a row list, merging tool calls with their answers. */ -function pushBridgeEvent( - rows: StreamRow[], - event: BridgeInboundEvent, - attempt: AttemptBoundary = { at: null }, -): void { - if (event.type === "attempt") { - if (event.action === "mark") attempt.at = rows.length; - else if (event.action === "clear") attempt.at = null; - else { - if (attempt.at !== null && attempt.at < rows.length) { - rows.length = attempt.at; - } - attempt.at = null; - } - return; - } - if (event.type === "tool_call") { - pushToolCall(rows, { - name: event.name, - ...(event.detail !== undefined ? { arguments: event.detail } : {}), - ...(event.callId !== undefined ? { callId: event.callId } : {}), - }); - return; - } - if (event.type === "tool_result") { - pushToolResult(rows, { - name: event.name, - content: event.detail ?? (event.isError ? "error" : "ok"), - isError: event.isError === true, - ...(event.callId !== undefined ? { callId: event.callId } : {}), - }); - return; - } - const row = rowFromBridgeEvent(event); - if (row) rows.push(row); -} - -/** - * Map one child reactor/stream event to zero or more StreamRows for observe. - * Pass a shared StreamMapContext across a live child session for tool-name - * fidelity (same as mapProductionEvent). - */ -export function mapChildStreamEvent( - event: ReactorLikeEvent, - ctx?: StreamMapContext, -): StreamRow[] { - return rowsFromBridgeEvents(mapProductionEvent(event, ctx)); -} - -/** - * Fold a sequence of child events through a shared map context into rows. - * Suitable for seeding observe from a retained child event log. - */ -export function mapChildStreamSequence( - events: readonly ReactorLikeEvent[], - ctx: StreamMapContext = createStreamMapContext(), -): StreamRow[] { - const rows: StreamRow[] = []; - const attempt: AttemptBoundary = { at: null }; - for (const event of events) { - for (const mapped of mapProductionEvent(event, ctx)) { - pushBridgeEvent(rows, mapped, attempt); - } - } - return rows; -} - -/** - * Coalesce assistant.delta tokens into a single assistant row when the - * sequence ends or a non-delta event arrives. Useful for pure seed paths - * that do not run the live bridge bag. - */ -export function rowsFromBridgeEventsCoalesced( - events: readonly BridgeInboundEvent[], -): StreamRow[] { - const rows: StreamRow[] = []; - let deltaBuf = ""; - const flushDelta = (): void => { - if (deltaBuf.length === 0) return; - rows.push({ role: "assistant", text: deltaBuf }); - deltaBuf = ""; - }; - for (const event of events) { - if (event.type === "assistant.delta") { - deltaBuf += event.text; - continue; - } - flushDelta(); - pushBridgeEvent(rows, event); - } - flushDelta(); - return rows; -}