diff --git a/src/__tests__/orchestrator/codex-preview-byte-budget.test.ts b/src/__tests__/orchestrator/codex-preview-byte-budget.test.ts new file mode 100644 index 000000000..2c662f94b --- /dev/null +++ b/src/__tests__/orchestrator/codex-preview-byte-budget.test.ts @@ -0,0 +1,170 @@ +// #1516 — the cumulative byte budget's POINT OF FACT in the Codex lane. +// +// PanelAgent gates the per-conversation preview budget when it COMPOSES a turn, +// but it cannot see the bytes of the batch already in flight — only the backend +// can, because the fetch happens there. So the codex backend enforces the same +// cumulative byte budget at the image loop: an automatic preview that would +// arrive past the budget is NOT attached, and the turn text corrects the claim +// with get_image coordinates (the same discipline as PanelAgent's drain trim, +// one layer down). A user's explicit attachment (no `automatic` flag) is never +// touched by this budget. + +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; + +vi.mock("../../utils/logger.js", () => ({ + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +type BackendModule = typeof import("../../orchestrator/codex-backend.js"); +type Backend = InstanceType; + +let CodexBackend: BackendModule["CodexBackend"]; +let MAX_SESSION_PREVIEW_BYTES: number; + +beforeAll(async () => { + vi.resetModules(); + ({ CodexBackend } = await import("../../orchestrator/codex-backend.js")); + ({ MAX_SESSION_PREVIEW_BYTES } = await import("../../orchestrator/preview-budget.js")); +}); + +afterAll(() => { + vi.unstubAllGlobals(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const IMAGE_BYTES = 100; + +/** A /view fetch that serves a fixed-size PNG. */ +function stubImageFetch(): void { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + const body = Buffer.alloc(IMAGE_BYTES, 7); + return new Response(body, { + status: 200, + headers: { "content-type": "image/png" }, + }); + }), + ); +} + +interface Captured { + turnInput?: Array>; +} + +/** The smallest app-server client that carries one turn: thread/start, one + * turn/start (capturing its input), then a matching turn/completed. */ +function fakeClient(captured: Captured) { + const client: Record = { + notificationHandler: null, + exitError: undefined, + // Never settles: the app-server outlives every turn in these tests. + exitPromise: new Promise(() => {}), + close: async () => {}, + }; + client.request = vi.fn(async (method: string, params: Record) => { + if (method === "thread/start" || method === "thread/resume") { + return { thread: { id: "thread-1" }, model: "gpt-5.6-sol" }; + } + if (method === "turn/start") { + captured.turnInput = params.input as Array>; + // Complete the turn AFTER the response's .then has published the turn id + // (the notification handler buffers until then) — a macrotask lands after. + setTimeout(() => { + (client.notificationHandler as ((m: unknown) => void) | null)?.({ + method: "turn/completed", + params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed" } }, + }); + }, 0); + return { turn: { id: "turn-1" } }; + } + throw new Error(`unexpected request: ${method}`); + }); + return client; +} + +/** Drive one turn carrying `images` through a backend whose cumulative preview + * byte ledger starts at `priorBytes`, returning what turn/start received. */ +async function runOneTurn( + images: Array<{ filename: string; automatic?: boolean }>, + priorBytes: number, +): Promise<{ captured: Captured; be: Backend }> { + stubImageFetch(); + const be = new CodexBackend({ comfyuiUrl: "http://127.0.0.1:8188" }) as Backend; + const captured: Captured = {}; + const priv = be as unknown as Record; + priv.client = fakeClient(captured); + // The ledger is a property OF THE THREAD: pre-seed both halves so run()'s + // fresh-thread reset does not wipe the delivered-so-far bytes. + priv.previewBytes = priorBytes; + priv.previewBytesThread = "thread-1"; + + const channel = (async function* () { + yield { text: "here are the outputs", images }; + })(); + const events: unknown[] = []; + for await (const ev of be.run({ channel } as never)) events.push(ev); + await be.close?.(); + return { captured, be }; +} + +function localImages(captured: Captured): Array> { + return (captured.turnInput ?? []).filter((i) => i.type === "localImage"); +} + +function turnText(captured: Captured): string { + return String((captured.turnInput ?? []).find((i) => i.type === "text")?.text ?? ""); +} + +describe("codex cumulative preview byte budget (#1516)", () => { + it("charges delivered automatic previews against the conversation's byte ledger", async () => { + const { captured, be } = await runOneTurn( + [{ filename: "auto.png", automatic: true }, { filename: "user.png" }], + 0, + ); + // Both images ride (budget untouched); only the AUTOMATIC one charges. + expect(localImages(captured)).toHaveLength(2); + expect(be.automaticPreviewBytes?.()).toBe(IMAGE_BYTES); + }); + + it("withholds automatic previews past the byte budget — and the turn says so with coordinates", async () => { + const { captured, be } = await runOneTurn( + [{ filename: "auto.png", automatic: true }, { filename: "user.png" }], + MAX_SESSION_PREVIEW_BYTES, + ); + + // The automatic preview is NOT attached; the user's own attachment is not + // this budget's business and still rides. + expect(localImages(captured)).toHaveLength(1); + const text = turnText(captured); + expect(text).toContain("[panel note:"); + expect(text).toContain("cumulative automatic-preview byte budget"); + expect(text).toContain("auto.png"); + expect(text).toContain('get_image action:"get"'); + // Nothing was delivered, so nothing was charged. + expect(be.automaticPreviewBytes?.()).toBe(MAX_SESSION_PREVIEW_BYTES); + }); + + it("resets the byte ledger when a DIFFERENT thread starts", async () => { + stubImageFetch(); + const be = new CodexBackend({ comfyuiUrl: "http://127.0.0.1:8188" }) as Backend; + const priv = be as unknown as Record; + // A prior thread's ledger… + priv.previewBytes = MAX_SESSION_PREVIEW_BYTES; + priv.previewBytesThread = "thread-OLD"; + // …must not follow a fresh thread, which provably holds no previews yet. + const captured: Captured = {}; + priv.client = fakeClient(captured); // fake answers thread-1 + const channel = (async function* () { + yield { text: "fresh conversation", images: [{ filename: "auto.png", automatic: true }] }; + })(); + for await (const _ of be.run({ channel } as never)) void _; + await be.close?.(); + + expect(localImages(captured)).toHaveLength(1); + expect(be.automaticPreviewBytes?.()).toBe(IMAGE_BYTES); + }); +}); diff --git a/src/__tests__/orchestrator/session-preview-budget.test.ts b/src/__tests__/orchestrator/session-preview-budget.test.ts new file mode 100644 index 000000000..c845b6c71 --- /dev/null +++ b/src/__tests__/orchestrator/session-preview-budget.test.ts @@ -0,0 +1,332 @@ +// #1516 — the CUMULATIVE, per-conversation budget on automatic run-completion +// previews. +// +// The per-turn ceiling (MAX_RUN_COMPLETION_IMAGE_ATTACHMENTS, locked in +// run-completion-continuation.test.ts) bounds ONE turn, but Codex persists every +// delivered preview as an inline input_image data URL and its compaction path +// recopies them without a media-aware bound (openai/codex#33493) — so N turns +// accumulating 8N images is the compounding shape the per-turn cap cannot see. +// Measured on the reporter's session: 265 persisted input_images, an 18.2 GiB +// rollout, ~487 MB recopied per later compaction. +// +// These tests lock what the session budget promises: +// 1. the budget is CUMULATIVE ACROSS TURNS — 8 + 2, then none, with the +// notice naming the bound that actually fired; +// 2. the BYTE half binds even when the count half has room (a count-only cap +// lies about cost — the reporter rejected exactly that); +// 3. a user's explicit attachment NEVER spends the automatic-preview budget; +// 4. the ledger persists across an orchestrator restart and is adopted ONLY +// by the exact conversation it was recorded against. + +import { beforeAll, beforeEach, afterEach, describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { + AgentBackend, + AgentEvent, + BackendStartOptions, + ModelChoice, +} from "../../orchestrator/agent-backend.js"; +import { CLAUDE_CAPABILITIES } from "../../orchestrator/agent-backend.js"; + +let PanelAgentManager: typeof import("../../orchestrator/panel-agent.js").PanelAgentManager; +let SessionStore: typeof import("../../orchestrator/session-store.js").SessionStore; +let MAX_SESSION_PREVIEW_ATTACHMENTS: number; +let MAX_SESSION_PREVIEW_BYTES: number; + +beforeAll(async () => { + // Small budgets so the cumulative bound is reachable in a handful of turns, + // and a short freeze window — both read at module load, hence the dynamic + // imports below (same pattern as run-completion-continuation.test.ts). + process.env.COMFYUI_MCP_TURN_IDLE_MS = "150"; + process.env.COMFYUI_MCP_SESSION_PREVIEW_ATTACHMENTS = "10"; + process.env.COMFYUI_MCP_SESSION_PREVIEW_BYTES = "1000"; + ({ PanelAgentManager } = await import("../../orchestrator/panel-agent.js")); + ({ SessionStore } = await import("../../orchestrator/session-store.js")); + ({ MAX_SESSION_PREVIEW_ATTACHMENTS, MAX_SESSION_PREVIEW_BYTES } = await import( + "../../orchestrator/preview-budget.js" + )); +}); + +async function waitFor(cond: () => boolean, timeoutMs = 2000): Promise { + const start = Date.now(); + while (!cond()) { + if (Date.now() - start > timeoutMs) throw new Error("waitFor timeout"); + await new Promise((r) => setTimeout(r, 5)); + } +} + +/** A backend that holds each turn open until the test releases it (so arrivals + * queue deterministically), records what each turn carried, and reports a + * controllable automatic-preview byte count — the #1516 ledger input only a + * backend can know (it is where the fetch happens). */ +class BudgetBackend implements AgentBackend { + readonly id = "claude" as const; + readonly capabilities = CLAUDE_CAPABILITIES; + turns: string[] = []; + turnImages: string[][] = []; + /** Bytes this backend pretends to have delivered as automatic previews. */ + previewBytes = 0; + private release: (() => void) | null = null; + + constructor(private readonly sessionId = "sess-budget") {} + + automaticPreviewBytes(): number { + return this.previewBytes; + } + + async *run(opts: BackendStartOptions): AsyncGenerator { + yield { type: "session", sessionId: this.sessionId }; + let turnSeq = 0; + for await (const turn of opts.channel) { + this.turns.push((turn as { text?: string }).text ?? ""); + this.turnImages.push((turn.images ?? []).map((image) => image.filename)); + turnSeq += 1; + await new Promise((resolve) => { + this.release = resolve; + }); + yield { type: "result", ok: true, subtype: "success", turn: turnSeq }; + } + } + + finishTurn(): void { + const r = this.release; + this.release = null; + r?.(); + } + + async interrupt(): Promise { + this.finishTurn(); + } + async listModels(): Promise { + return []; + } +} + +function makeManager(backend: AgentBackend, sessionStore?: InstanceType) { + const manager = new PanelAgentManager({ + mcpServers: {}, + systemAppend: "", + model: "claude-test", + onSay: () => {}, + onTurn: () => {}, + makeBackend: () => backend, + sessionStore, + } as never); + return manager; +} + +/** A matched run completion with `n` named outputs. */ +function completion(promptId: string, n: number, tag: string) { + return { + kind: "executed" as const, + prompt_id: promptId, + run_correlation: "matched" as const, + images: Array.from({ length: n }, (_, i) => ({ filename: `${tag}_${i}.png` })), + }; +} + +let dir: string; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "preview-budget-")); +}); + +afterEach(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe("cumulative session preview budget (#1516)", () => { + it("is shared ACROSS turns: 8 ride, then 2, then none — and the notices name the conversation bound", async () => { + const backend = new BudgetBackend(); + const manager = makeManager(backend); + const tab = "tab-cumulative"; + + manager.send(tab, "start"); + await waitFor(() => backend.turns.length >= 1); + backend.finishTurn(); + + // Turn 2: the per-turn 8 all ride (session budget 10 has room). + manager.injectEvent(tab, completion("p-a", 8, "a")); + await waitFor(() => backend.turns.length >= 2); + expect(backend.turnImages[1]).toHaveLength(8); + expect(backend.turns[1]).toContain("attached below"); + backend.finishTurn(); + + // Turn 3: only 2 of the conversation's 10 remain — the SESSION bound, not + // the turn's 8, is what fires, and the text must say which bound spoke. + manager.injectEvent(tab, completion("p-b", 8, "b")); + await waitFor(() => backend.turns.length >= 3); + expect(backend.turnImages[2]).toHaveLength(2); + expect(backend.turns[2]).toContain("cumulative automatic-preview budget"); + expect(backend.turns[2]).toContain("nearly spent"); + // …with get_image coordinates for what was withheld, never a silent drop. + expect(backend.turns[2]).toContain("get_image"); + backend.finishTurn(); + + // Turn 4: the conversation budget is spent. The completion still arrives, + // all outputs stay visible in the panel, but ZERO pixels ride the turn. + manager.injectEvent(tab, completion("p-c", 4, "c")); + await waitFor(() => backend.turns.length >= 4); + expect(backend.turnImages[3]).toHaveLength(0); + expect(backend.turns[3]).toContain("NONE of these outputs are attached"); + expect(backend.turns[3]).toContain("cumulative automatic-preview budget"); + expect(backend.turns[3]).toContain("is spent"); + backend.finishTurn(); + + // Diagnostics: 10 delivered, 10 withheld (6 + 4) — accounted where decided. + const agent = (manager as unknown as { agents: Map }) + .agents.get(tab); + expect(agent?.previewBudgetStatus()).toMatchObject({ + images: 10, + withheld: 10, + budgetImages: MAX_SESSION_PREVIEW_ATTACHMENTS, + budgetBytes: MAX_SESSION_PREVIEW_BYTES, + }); + }); + + it("the BYTE budget binds even when the count budget has room", async () => { + const backend = new BudgetBackend(); + const manager = makeManager(backend); + const tab = "tab-bytes"; + + manager.send(tab, "start"); + await waitFor(() => backend.turns.length >= 1); + backend.finishTurn(); + + // The backend reports a conversation already carrying a full byte budget of + // automatic previews (e.g. adopted from the durable store after a restart): + // not one more pixel rides, however much count budget remains. + backend.previewBytes = MAX_SESSION_PREVIEW_BYTES; + manager.injectEvent(tab, completion("p-fat", 3, "fat")); + await waitFor(() => backend.turns.length >= 2); + expect(backend.turnImages[1]).toHaveLength(0); + expect(backend.turns[1]).toContain("NONE of these outputs are attached"); + expect(backend.turns[1]).toContain("is spent"); + backend.finishTurn(); + }); + + it("a user's explicit attachment NEVER spends the automatic-preview budget", async () => { + const backend = new BudgetBackend(); + const manager = makeManager(backend); + const tab = "tab-user-image"; + + manager.send(tab, "start"); + await waitFor(() => backend.turns.length >= 1); + backend.finishTurn(); + + // Spend the whole conversation budget on automatic previews (8 + 2). + manager.injectEvent(tab, completion("p-a", 8, "a")); + await waitFor(() => backend.turns.length >= 2); + backend.finishTurn(); + manager.injectEvent(tab, completion("p-b", 8, "b")); + await waitFor(() => backend.turns.length >= 3); + backend.finishTurn(); + + // The conversation's automatic budget is spent — but the user's OWN image + // is a separate, reviewed policy and must still reach the model. + manager.send(tab, "what do you see?", { images: [{ filename: "user.png" }] }); + await waitFor(() => backend.turns.length >= 4); + expect(backend.turnImages[3]).toEqual(["user.png"]); + backend.finishTurn(); + }); + + it("the ledger persists to the session store as previews are delivered", async () => { + const store = new SessionStore(9871, { dir }); + const backend = new BudgetBackend(); + const manager = makeManager(backend, store); + const tab = "tab-persist"; + + manager.send(tab, "start"); + await waitFor(() => backend.turns.length >= 1); + backend.finishTurn(); + + manager.injectEvent(tab, completion("p-a", 8, "a")); + await waitFor(() => backend.turns.length >= 2); + backend.finishTurn(); + + // The store's entry is keyed to the LIVE session id (written by the + // manager's onSession) and carries what the conversation actually received. + expect(store.previewLedger(tab)).toEqual({ sid: "sess-budget", images: 8, bytes: 0 }); + }); + + it("a durable ledger is adopted ONLY by the exact conversation it was recorded against", async () => { + const store = new SessionStore(9872, { dir }); + + // Matching conversation: the store says sess-budget already received a full + // budget of previews before the restart, so the resumed conversation does + // not get one more automatic pixel. + const tabMatch = "tab-adopt"; + store.set(tabMatch, "sess-budget"); + expect( + store.setPreviewLedger(tabMatch, "sess-budget", { + images: MAX_SESSION_PREVIEW_ATTACHMENTS, + bytes: 0, + }), + ).toBe(true); + const backendMatch = new BudgetBackend("sess-budget"); + const managerMatch = makeManager(backendMatch, store); + managerMatch.send(tabMatch, "resume me"); + await waitFor(() => backendMatch.turns.length >= 1); + backendMatch.finishTurn(); + managerMatch.injectEvent(tabMatch, completion("p-r", 4, "r")); + await waitFor(() => backendMatch.turns.length >= 2); + expect(backendMatch.turnImages[1]).toHaveLength(0); + expect(backendMatch.turns[1]).toContain("is spent"); + backendMatch.finishTurn(); + + // Mismatched conversation: the stored ledger belongs to sess-old, the live + // session is sess-new — a fresh conversation must start at ZERO, not inherit + // media cost it never incurred. + const tabFresh = "tab-no-adopt"; + store.set(tabFresh, "sess-old"); + store.setPreviewLedger(tabFresh, "sess-old", { + images: MAX_SESSION_PREVIEW_ATTACHMENTS, + bytes: 0, + }); + const backendFresh = new BudgetBackend("sess-new"); + const managerFresh = makeManager(backendFresh, store); + managerFresh.send(tabFresh, "fresh"); + await waitFor(() => backendFresh.turns.length >= 1); + backendFresh.finishTurn(); + managerFresh.injectEvent(tabFresh, completion("p-f", 8, "f")); + await waitFor(() => backendFresh.turns.length >= 2); + expect(backendFresh.turnImages[1]).toHaveLength(8); + backendFresh.finishTurn(); + }); +}); + +describe("session store preview ledger (#1516)", () => { + it("refuses a ledger for any session but the one it describes", () => { + const store = new SessionStore(9873, { dir }); + const key = "tab-store"; + + // No entry at all → refused, not invented. + expect(store.setPreviewLedger(key, "sess-1", { images: 1, bytes: 2 })).toBe(false); + + store.set(key, "sess-1"); + expect(store.setPreviewLedger(key, "sess-2", { images: 1, bytes: 2 })).toBe(false); + expect(store.previewLedger(key)).toBeUndefined(); + + expect(store.setPreviewLedger(key, "sess-1", { images: 7, bytes: 123 })).toBe(true); + expect(store.previewLedger(key)).toEqual({ sid: "sess-1", images: 7, bytes: 123 }); + + // set() on the SAME session preserves the ledger (it is a property of the + // conversation)… + store.set(key, "sess-1"); + expect(store.previewLedger(key)).toEqual({ sid: "sess-1", images: 7, bytes: 123 }); + + // …and a NEW session id drops it: a fresh conversation provably holds no + // previews yet, so carrying the old ledger would starve it. + store.set(key, "sess-2"); + expect(store.previewLedger(key)).toBeUndefined(); + + // The ledger survives a reload (the whole point: an orchestrator restart + // must not reset a budget the living conversation keeps accumulating). + store.set(key, "sess-2"); + store.setPreviewLedger(key, "sess-2", { images: 3, bytes: 45 }); + const reloaded = new SessionStore(9873, { dir }); + expect(reloaded.previewLedger(key)).toEqual({ sid: "sess-2", images: 3, bytes: 45 }); + }); +}); diff --git a/src/orchestrator/agent-backend.ts b/src/orchestrator/agent-backend.ts index 486e3dd75..a390adfdd 100644 --- a/src/orchestrator/agent-backend.ts +++ b/src/orchestrator/agent-backend.ts @@ -274,6 +274,17 @@ export interface AgentBackend { setModel?(model: string): Promise; /** Models the current account can use (empty if `modelEnumeration` is false). */ listModels(): Promise; + /** + * #1516 — fetched BYTES of AUTOMATIC run-completion previews this backend has + * actually delivered into the CURRENT provider conversation (ImageRefs flagged + * `automatic`; a user's explicit attachments are never counted). PanelAgent + * reads this when composing an injection so the cumulative per-conversation + * byte budget (preview-budget.ts) is enforced against real fetch sizes, not a + * count-times-constant guess. Optional: a backend that does not report bytes + * is bounded by the image COUNT alone. Reset by the backend whenever the + * underlying provider conversation changes (a fresh thread starts at zero). + */ + automaticPreviewBytes?(): number; /** Permanently dispose of the backend's resources: kill any child process tree, * remove listeners, drop the live connection. Called by PanelAgent.stop() and on * every path that retires/replaces an agent (reset, effort restart, stopAll). diff --git a/src/orchestrator/codex-backend.ts b/src/orchestrator/codex-backend.ts index 5d5897782..3c280c55f 100644 --- a/src/orchestrator/codex-backend.ts +++ b/src/orchestrator/codex-backend.ts @@ -56,6 +56,7 @@ import { stampTurn, } from "./agent-backend.js"; import type { ImageRef } from "./panel-agent.js"; +import { MAX_SESSION_PREVIEW_BYTES, previewByteBudgetLabel } from "./preview-budget.js"; function msgOf(err: unknown): string { return errorText(err); @@ -675,6 +676,14 @@ export class CodexBackend implements AgentBackend { * Tracked so each turn cleans up its own files, and close() sweeps any * stragglers. */ private tempImageFiles = new Set(); + /** #1516 — fetched bytes of AUTOMATIC previews delivered into the CURRENT + * thread (refs flagged `automatic`; user attachments never count). Codex + * persists every localImage as an inline input_image data URL and its + * compaction recopies them unboundedly, so this is the ledger the cumulative + * byte budget enforces against. Reset when the thread changes. */ + private previewBytes = 0; + /** The thread {@link previewBytes} belongs to (a fresh thread starts at 0). */ + private previewBytesThread: string | null = null; constructor(deps: CodexBackendDeps = {}) { this.deps = deps; @@ -733,14 +742,16 @@ export class CodexBackend implements AgentBackend { /** * Fetch a ComfyUI image (/view) and spill the bytes to a temp file, returning - * its absolute path — or null on any failure (the text reference still names the - * image as a fallback). The app-server `turn/start` `localImage` input item takes - * a FILE PATH (mirrors the codex CLI `-i, --image `), so unlike Claude - * (inline base64) we must write the bytes to disk. Mirrors - * ClaudeBackend.fetchImageBlock's source/size guards. Each written path is - * tracked in tempImageFiles for per-turn + close() cleanup. + * its absolute path and byte size — or null on any failure (the text reference + * still names the image as a fallback). The app-server `turn/start` + * `localImage` input item takes a FILE PATH (mirrors the codex CLI + * `-i, --image `), so unlike Claude (inline base64) we must write the + * bytes to disk. Mirrors ClaudeBackend.fetchImageBlock's source/size guards. + * Each written path is tracked in tempImageFiles for per-turn + close() + * cleanup. The byte count rides along so the caller can charge the #1516 + * cumulative preview ledger with the size that was ACTUALLY delivered. */ - private async fetchImageFile(ref: ImageRef): Promise { + private async fetchImageFile(ref: ImageRef): Promise<{ path: string; bytes: number } | null> { if (!this.deps.comfyuiUrl || !ref?.filename) return null; try { const u = new URL("/view", this.deps.comfyuiUrl); @@ -772,12 +783,19 @@ export class CodexBackend implements AgentBackend { ); await fsp.writeFile(file, buf); this.tempImageFiles.add(file); - return file; + return { path: file, bytes: buf.length }; } catch { return null; } } + /** #1516 — fetched bytes of AUTOMATIC previews delivered into the current + * thread, as the AgentBackend port exposes them: PanelAgent adds this to the + * durable pre-restart base when it checks the cumulative session budget. */ + automaticPreviewBytes(): number { + return this.previewBytes; + } + /** * How long a turn's localImage files survive after the turn ends (#1152). * @@ -970,6 +988,13 @@ export class CodexBackend implements AgentBackend { this.needsSystemPreamble = !!this.deps.systemAppend; } // The thread id is our session id (PanelAgent persists it for resume). + // #1516 — the cumulative preview byte ledger is a property OF THE THREAD: + // a different conversation (fresh start, or a resume of another id after a + // fork) provably holds none of this process's earlier previews. + if (this.threadId !== this.previewBytesThread) { + this.previewBytesThread = this.threadId; + this.previewBytes = 0; + } yield { type: "session", sessionId: this.threadId, @@ -1297,13 +1322,46 @@ export class CodexBackend implements AgentBackend { { type: "text", text: turnText, text_elements: [] }, ]; const turnTempFiles: string[] = []; + // #1516 — the BYTE budget's point of fact. PanelAgent gates the cumulative + // session budget when it COMPOSES the turn, but it cannot see the bytes of + // the batch already in flight, so this loop is the backstop: an automatic + // preview that would arrive past the conversation's cumulative byte budget + // is NOT attached, and the turn text says so with fetchable coordinates — + // the same correct-the-claim discipline as PanelAgent's drain trim, one + // layer down where the real fetch sizes are known. A user's own attachment + // (no `automatic` flag) is never touched by this budget. + const byteWithheld: ImageRef[] = []; for (const ref of turn.images ?? []) { - const file = await this.fetchImageFile(ref); - if (file) { - turnTempFiles.push(file); - turnInput.push({ type: "localImage", path: file }); + if (ref?.automatic && this.previewBytes >= MAX_SESSION_PREVIEW_BYTES) { + byteWithheld.push(ref); + continue; + } + const got = await this.fetchImageFile(ref); + if (got) { + turnTempFiles.push(got.path); + turnInput.push({ type: "localImage", path: got.path }); + if (ref.automatic) this.previewBytes += got.bytes; } } + if (byteWithheld.length) { + const refs = byteWithheld + .map((i) => { + const type = i.type ?? "output"; + const sub = i.subfolder ? `, subfolder:"${i.subfolder}"` : ""; + return type === "output" && !i.subfolder + ? i.filename + : `${i.filename} (type:"${type}"${sub})`; + }) + .join(", "); + turnInput[0]!.text += + `\n\n[panel note: ${byteWithheld.length} automatic preview image(s) named above were NOT attached to this turn: ` + + `this conversation's cumulative automatic-preview byte budget (~${previewByteBudgetLabel()}) is spent — the bound that keeps a long session's rollout from growing without limit (every attached preview is retained inline and recopied at compaction). ` + + `The outputs are already shown to the user in the panel — fetch one with get_image action:"get" if you need to look: ${refs}.]`; + logger.info( + `[codex] withheld ${byteWithheld.length} automatic preview image(s) at the cumulative byte budget ` + + `(${this.previewBytes}/${MAX_SESSION_PREVIEW_BYTES} bytes delivered to this thread); the turn says so and names them (#1516)`, + ); + } try { // turn/start delivers the user text plus any resolved image input items. diff --git a/src/orchestrator/panel-agent.ts b/src/orchestrator/panel-agent.ts index 47a7ae1fc..0a0b3706f 100644 --- a/src/orchestrator/panel-agent.ts +++ b/src/orchestrator/panel-agent.ts @@ -27,6 +27,12 @@ import { COMPLETION_DISAGREEMENT_NOTE } from "./download-done-guard.js"; import { downloadsAtRiskOfRespawn } from "../services/download-jobs.js"; import { orphanedByDeferredRespawnNote } from "../services/panel-secrets.js"; import { errorText, promptText } from "./error-text.js"; +import { + MAX_SESSION_PREVIEW_ATTACHMENTS, + MAX_SESSION_PREVIEW_BYTES, + previewByteBudgetLabel, + type PreviewLedger, +} from "./preview-budget.js"; import type { SessionStore } from "./session-store.js"; import type { AgentBackend, AgentEvent, NeutralTurn } from "./agent-backend.js"; import { type AudioRef, dedupeAudioRefs, noAudioPartText } from "./audio-attachment.js"; @@ -252,6 +258,11 @@ export interface ImageRef { filename: string; subfolder?: string; type?: string; // "input" | "output" | "temp" (ComfyUI /view folder) + /** #1516 — set by PanelAgent on AUTOMATIC run-completion previews (never on a + * user's explicit attachment, which keeps its own reviewed policy). This is + * what the backend keys the cumulative per-conversation byte ledger on, and + * it never arrives from the panel wire — only injectEvent sets it. */ + automatic?: boolean; } /** One queued user turn (a panel message, or an injected panel event). @@ -330,6 +341,15 @@ export interface PanelAgentDeps { /** Report the SDK session id once known, so the panel can persist/resume it. * `model` is the SDK-resolved model (#376), used to correct the ready banner. */ onSession?: (tabId: string, sessionId: string, model?: string) => void; + /** #1516 — the cumulative automatic-preview ledger changed (previews were + * committed to a turn, or delivery stopped at the budget). The manager + * persists it beside the session id so an orchestrator RESTART does not + * reset the budget while the provider conversation it bounds lives on. */ + onPreviewLedger?: (tabId: string, ledger: PreviewLedger) => void; + /** #1516 — the durable ledger the session store holds for the conversation + * this agent is expected to resume. Adopted ONLY when the live session id + * proves to be exactly `sid`; a different conversation starts at zero. */ + initialPreviewLedger?: PreviewLedger & { sid: string }; /** Report each turn's ending assistant-message UUID — the anchor the panel * stores so a later "rewind conversation to here" can fork the session at that * point (resumeSessionAt + forkSession). */ @@ -514,6 +534,23 @@ export class PanelAgent { private pendingRewind: { anchor: string | null } | null = null; /** Captured from the session's init message; enables resume across restarts. */ sessionId: string | null = null; + /** #1516 — cumulative AUTOMATIC previews committed to turns of the CURRENT + * provider conversation (charged at the drain, the point of fact). Reset + * whenever the session id changes: a fresh conversation starts at zero. */ + private previewImagesCharged = 0; + /** #1516 — the byte half of the ledger ADOPTED from the durable store (see + * initialPreviewLedger). Live bytes come from the backend; the total is + * previewBytesBase + backend.automaticPreviewBytes(). */ + private previewBytesBase = 0; + /** #1516 — automatic previews composed but NOT delivered, accounted where the + * withholding was decided (session budget at inject, either budget at the + * drain trim). Diagnostics; each was named in an agent-visible note. */ + private previewWithheldSession = 0; + /** #1516 — the session id the charged ledger belongs to; reconciled lazily. */ + private previewLedgerSid: string | null = null; + /** #1516 — durable ledger awaiting adoption once the live session id proves + * to be the conversation it was recorded against (never applied blindly). */ + private pendingPreviewLedger: (PreviewLedger & { sid: string }) | null = null; /** Id of the assistant message currently streaming (from message_start), so * stream deltas and the final committed `say` share one bubble id. */ private streamMsgId: string | null = null; @@ -544,6 +581,7 @@ export class PanelAgent { this.deps = deps; this.model = deps.model; this.effort = deps.effort; + this.pendingPreviewLedger = deps.initialPreviewLedger ?? null; // Default to the Claude adapter; injectable so a future toggle can swap it. this.backend = backend ?? @@ -711,6 +749,83 @@ export class PanelAgent { ); } + /** #1516 — keep the cumulative preview ledger attached to the conversation it + * measures. The session id only becomes known at the init event and can + * change under us (fresh fork, resume-miss restart, provider switch), so + * this runs lazily at every budget decision: a new id resets the ledger to + * zero, and the durable pending ledger is adopted ONLY when the live id is + * exactly the one it was recorded against — a ledger applied to the wrong + * conversation would either starve a fresh one or un-bound an old one. */ + private reconcilePreviewLedger(): void { + if (this.sessionId === this.previewLedgerSid) return; + this.previewLedgerSid = this.sessionId; + this.previewImagesCharged = 0; + this.previewBytesBase = 0; + this.previewWithheldSession = 0; + if (this.sessionId && this.pendingPreviewLedger) { + if (this.pendingPreviewLedger.sid === this.sessionId) { + this.previewImagesCharged = this.pendingPreviewLedger.images; + this.previewBytesBase = this.pendingPreviewLedger.bytes; + } + this.pendingPreviewLedger = null; + } + } + + /** #1516 — cumulative delivered automatic-preview BYTES for the current + * conversation: the durable base (pre-restart) plus what this process's + * backend actually fetched. Backends that do not report bytes contribute + * zero and are bounded by the image count alone. */ + private sessionPreviewBytes(): number { + return this.previewBytesBase + (this.backend.automaticPreviewBytes?.() ?? 0); + } + + /** #1516 — how many automatic previews this conversation may still receive: + * the tighter of the remaining COUNT and the remaining BYTES. Bytes + * delivered so far is what is knowable here — the in-flight turn's fetches + * have not happened yet — so the byte gate can overshoot by at most one + * turn's worth; the backend enforces the same budget at fetch time, which + * is the bound of record (see codex-backend's image loop). */ + private sessionPreviewBudgetLeft(): number { + this.reconcilePreviewLedger(); + if (this.sessionPreviewBytes() >= MAX_SESSION_PREVIEW_BYTES) return 0; + return Math.max(0, MAX_SESSION_PREVIEW_ATTACHMENTS - this.previewImagesCharged); + } + + /** #1516 — the cumulative automatic-preview ledger, for diagnostics: what was + * delivered to the current provider conversation, what was withheld at the + * session budget, and the budget itself. */ + previewBudgetStatus(): PreviewLedger & { + withheld: number; + budgetImages: number; + budgetBytes: number; + } { + this.reconcilePreviewLedger(); + return { + images: this.previewImagesCharged, + bytes: this.sessionPreviewBytes(), + withheld: this.previewWithheldSession, + budgetImages: MAX_SESSION_PREVIEW_ATTACHMENTS, + budgetBytes: MAX_SESSION_PREVIEW_BYTES, + }; + } + + /** #1516 — persist the ledger through the manager so an orchestrator restart + * does not reset a budget the still-living provider conversation keeps + * accumulating against. Bytes are read NOW (the in-flight turn's fetches + * lag), so the stored value is a lower bound — disclosed, and refreshed on + * every later change. */ + private reportPreviewLedger(): void { + if (!this.deps.onPreviewLedger) return; + try { + this.deps.onPreviewLedger(this.tabId, { + images: this.previewImagesCharged, + bytes: this.sessionPreviewBytes(), + }); + } catch (err) { + logger.debug(`[panel-agent ${this.short()}] preview-ledger report failed: ${msgOf(err)}`); + } + } + /** Drop a still-queued message (the user cancelled/edited it before the agent * got to it). Returns true if it was found and removed; false if it was * already dequeued (the turn started — too late to cancel). */ @@ -809,10 +924,32 @@ export class PanelAgent { 0, MAX_RUN_COMPLETION_IMAGE_ATTACHMENTS - this.queuedPreviewImageCount(), ); + // #1516 — ...AND what is left of the CONVERSATION's cumulative budget. + // The per-turn bound above stops one turn from going unbounded; this one + // stops N turns from accumulating 8N inline images into a Codex rollout + // that compaction then recopies without a media-aware bound. Queued + // previews are charged against the session budget HERE too (they drain + // into this conversation), so a burst queueing behind a busy turn cannot + // over-commit it. `sessionBound` remembers WHICH budget spoke, so the + // notice below names the bound that actually fired. + const sessionBudgetLeft = Math.max( + 0, + this.sessionPreviewBudgetLeft() - this.queuedPreviewImageCount(), + ); + const sessionBound = sessionBudgetLeft < previewBudgetLeft; + const effectiveBudget = sessionBound ? sessionBudgetLeft : previewBudgetLeft; const attachedImgs = correlationIsUntrusted ? [] - : attachableImgs.slice(0, previewBudgetLeft); + : attachableImgs.slice(0, effectiveBudget); const omittedImgs = attachableImgs.length - attachedImgs.length; + // #1516 — account for what the SESSION budget withheld (diagnostics + + // durable ledger). Only the session-bound case counts here: a per-turn + // trim is the pre-existing turn budget doing its job, and an untrusted + // correlation never had pixels to withhold. + if (sessionBound && !correlationIsUntrusted && omittedImgs > 0) { + this.previewWithheldSession += omittedImgs; + this.reportPreviewLedger(); + } // The unnamed remainder. Every sentence below counts outputs with // `imgs.length` but attaches and names from `attachableImgs`, so on a MIXED // event — one output with a filename, one without — the two disagree and @@ -887,9 +1024,13 @@ export class PanelAgent { attachableImgs.length === 0 ? `The panel reported no usable filename for ${imgs.length === 1 ? "it" : "them"}, so ${imgs.length === 1 ? "it is" : "they are"} NOT attached to this agent turn and cannot be fetched by name — check get_history (action:"list") if you need to see what this run produced. ` : attachedImgs.length === 0 - ? `NONE of these outputs are attached to this agent turn: earlier completion(s) in this same turn already spent its ${MAX_RUN_COMPLETION_IMAGE_ATTACHMENTS}-image preview budget. All ${imgs.length} are shown to the user in the panel — fetch one with get_image action:"get" if you need to look: ${withheldRefs}. ` + ? sessionBound + ? `NONE of these outputs are attached to this agent turn: this conversation's cumulative automatic-preview budget (${MAX_SESSION_PREVIEW_ATTACHMENTS} images / ~${previewByteBudgetLabel()}) is spent — the bound that keeps a long session from accumulating unbounded inline media the provider retains and recopies at compaction. All ${imgs.length} are shown to the user in the panel — fetch one with get_image action:"get" if you need to look: ${withheldRefs}. ` + : `NONE of these outputs are attached to this agent turn: earlier completion(s) in this same turn already spent its ${MAX_RUN_COMPLETION_IMAGE_ATTACHMENTS}-image preview budget. All ${imgs.length} are shown to the user in the panel — fetch one with get_image action:"get" if you need to look: ${withheldRefs}. ` : omittedImgs > 0 - ? `The first ${attachedImgs.length} image(s) are attached below; all ${imgs.length} outputs are already shown to the user in the panel. ${omittedImgs} further preview(s) were omitted to keep this TURN's image context bounded — fetch one with get_image action:"get" if you need it: ${withheldRefs}. ` + ? sessionBound + ? `The first ${attachedImgs.length} image(s) are attached below; all ${imgs.length} outputs are already shown to the user in the panel. ${omittedImgs} further preview(s) were omitted because this conversation's cumulative automatic-preview budget (${MAX_SESSION_PREVIEW_ATTACHMENTS} images / ~${previewByteBudgetLabel()}) is nearly spent — fetch one with get_image action:"get" if you need it: ${withheldRefs}. ` + : `The first ${attachedImgs.length} image(s) are attached below; all ${imgs.length} outputs are already shown to the user in the panel. ${omittedImgs} further preview(s) were omitted to keep this TURN's image context bounded — fetch one with get_image action:"get" if you need it: ${withheldRefs}. ` : `The image(s) are attached below and already shown to the user in the panel. ` : `You cannot view images on this provider, but they are already shown to the user in the panel. ` : ``) + @@ -920,7 +1061,10 @@ export class PanelAgent { // up to the bound above. Past it (or for an UNDETERMINED origin) the text // says so and points at get_image, so a fetch is needed but never a guess. if (this.backend.capabilities.vision) { - images = attachedImgs.map((i) => ({ ...i, type: i.type ?? "output" })); + // `automatic` is what the backend's cumulative byte ledger keys on — + // a user's explicit attachment never carries it and never spends the + // session preview budget (#1516). + images = attachedImgs.map((i) => ({ ...i, type: i.type ?? "output", automatic: true })); } } else if (ev.kind === "ask_answer") { // #486 — the user ANSWERED a question card, but no tool call was alive to @@ -1429,16 +1573,36 @@ export class PanelAgent { // corrects the notices instead of assuming they agree with the outcome. // A user's own attachment is never touched — only `completionOnly` items, // which are the injected panel events. - let previewBudget = MAX_RUN_COMPLETION_IMAGE_ATTACHMENTS; + // The drain is also where the CUMULATIVE session budget (#1516) meets the + // fact of the turn: the ceiling is the tighter of the per-turn 8 and what + // the conversation has left. injectEvent already charged queued previews + // against its estimate, so an in-flight batch (invisible there) is the + // divergence this line absorbs — same estimate/fact split as the per-turn + // budget above. + let previewBudget = Math.min( + MAX_RUN_COMPLETION_IMAGE_ATTACHMENTS, + this.sessionPreviewBudgetLeft(), + ); const trimmedPreviews: ImageRef[] = []; + let deliveredPreviews = 0; let images = batch.flatMap((it) => { const refs = it.images ?? []; if (!it.completionOnly) return refs; const take = refs.slice(0, Math.max(0, previewBudget)); previewBudget -= take.length; + deliveredPreviews += take.length; trimmedPreviews.push(...refs.slice(take.length)); return take; }); + // Charge the conversation for what THIS turn actually carries, and persist + // the ledger so a restart does not reset the budget of a conversation that + // lives on. A replayed (interrupted-then-requeued) batch charges AGAIN — + // deliberately: the replay really does write those pixels into the + // provider thread a second time, which is the accumulation being bounded. + if (deliveredPreviews > 0) { + this.previewImagesCharged += deliveredPreviews; + this.reportPreviewLedger(); + } // A trim here means a notice ABOVE is now wrong: it was composed when its // images were going to ride this turn, and they are not. Measured sequence // — 8 previews in flight, a second completion queued behind them (its own @@ -1455,9 +1619,13 @@ export class PanelAgent { if (trimmedPreviews.length && this.backend.capabilities.vision) { text += `\n\n[panel note: ${trimmedPreviews.length} automatic preview image(s) were NOT attached to this turn. ` + - `Several run completions were merged into one turn and they SHARE a single ${MAX_RUN_COMPLETION_IMAGE_ATTACHMENTS}-image budget, ` + + `Several run completions were merged into one turn and they SHARE one bounded automatic-preview budget (at most ${MAX_RUN_COMPLETION_IMAGE_ATTACHMENTS} per turn, and a cumulative per-conversation ceiling of ${MAX_SESSION_PREVIEW_ATTACHMENTS} images / ~${previewByteBudgetLabel()}), ` + `so a notice above may say its images are "attached below" when they are not — where they disagree, THIS note is the accurate one. ` + `They are outputs on the ComfyUI side that this turn does not carry — fetch any of them with get_image action:"get" — ${describeImageRefs(trimmedPreviews)}.]`; + // These previews are withheld from the conversation for good (the note + // redirects to get_image), so the diagnostics counter accounts them. + this.previewWithheldSession += trimmedPreviews.length; + this.reportPreviewLedger(); logger.info( `[panel-agent ${this.short()}] trimmed ${trimmedPreviews.length} automatic preview image(s) at the drain ` + `(completions merged into one turn); the turn says so and names them (#1516)`, @@ -2407,6 +2575,18 @@ export class PanelAgentManager { this.opts.sessionStore?.set(id, sid, this.opts.identityForKey?.(id)); this.opts.onSession?.(id, sid, model); }, + // #1516 — persist the cumulative preview ledger beside the session id it + // belongs to. setPreviewLedger REFUSES a session mismatch, so a ledger is + // only ever stored against the exact conversation it measures — a stale + // report for a since-replaced session is dropped, not misfiled. + onPreviewLedger: (id, ledger) => { + const sid = this.agents.get(id)?.sessionId; + if (sid) this.opts.sessionStore?.setPreviewLedger(id, sid, ledger); + }, + // #1516 — hand the freshly-spawned agent the durable ledger of the + // conversation it is expected to resume; the agent adopts it only when + // the live session id proves to be that exact conversation. + initialPreviewLedger: this.opts.sessionStore?.previewLedger(tabId), onTurnAnchor: this.opts.onTurnAnchor, // Wrap onTurn so the manager learns when a turn ends — the safe point to // apply a deferred, session-restarting effort change. diff --git a/src/orchestrator/preview-budget.ts b/src/orchestrator/preview-budget.ts new file mode 100644 index 000000000..eb91a5431 --- /dev/null +++ b/src/orchestrator/preview-budget.ts @@ -0,0 +1,53 @@ +// #1516 — the CUMULATIVE, per-conversation budget on AUTOMATIC run-completion +// previews. +// +// Why a second budget on top of the per-turn one +// (MAX_RUN_COMPLETION_IMAGE_ATTACHMENTS in panel-agent.ts): the per-turn ceiling +// bounds ONE turn, but a long session drains 8 previews per turn into the SAME +// provider conversation, and Codex persists every delivered localImage as an +// inline `input_image` data URL in the thread rollout — which its compaction +// path then retains and recopies without a media-aware bound (openai/codex#33493). +// Measured on the reporter's session: 265 persisted input_images, an 18.2 GiB +// rollout, ~487 MB recopied per compaction, and a resumed app-server stalled at +// ~9 GB private bytes. The per-turn cap alone still lets N turns accumulate 8N +// images, so the conversation itself needs a ceiling. +// +// Two dimensions, because a count-only cap lies about cost: previews range from +// a 50 KB icon to the 12 MB per-file fetch cap, and it is the BYTES that Codex +// inlines and recopies. The count is known exactly at the drain (panel-agent +// charges it when previews are committed to a turn); the bytes are known only +// where the fetch happens (the backend), so byte accounting lives in the +// backend and panel-agent reads it back through AgentBackend.automaticPreviewBytes. +// +// Both bounds are deliberately CONSERVATIVE defaults, overridable for tuning and +// tests. Explicit USER attachments never spend this budget (the `automatic` flag +// on ImageRef is what separates them) — #1516 asks for those to keep their own +// reviewed policy. + +/** Cumulative AUTOMATIC preview images one provider conversation may receive + * before automatic delivery stops (the notice stays, the pixels stop, and the + * text says so with get_image coordinates). Overridable via + * COMFYUI_MCP_SESSION_PREVIEW_ATTACHMENTS. */ +export const MAX_SESSION_PREVIEW_ATTACHMENTS = + Number(process.env.COMFYUI_MCP_SESSION_PREVIEW_ATTACHMENTS) || 48; + +/** Cumulative fetched BYTES of automatic previews one provider conversation may + * receive. This is the bound that actually speaks to Codex's inline-media + * amplification: ~128 MiB of fetched bytes is ~170 MiB of base64 the rollout + * retains and every later compaction recopies. Overridable via + * COMFYUI_MCP_SESSION_PREVIEW_BYTES. */ +export const MAX_SESSION_PREVIEW_BYTES = + Number(process.env.COMFYUI_MCP_SESSION_PREVIEW_BYTES) || 128 * 1024 * 1024; + +/** The delivered-so-far ledger for ONE provider conversation (#1516). `images` + * is exact (charged at the drain); `bytes` is the sum the backend reports for + * fetches it actually completed, so it lags by the in-flight turn at most. */ +export interface PreviewLedger { + images: number; + bytes: number; +} + +/** Human-facing rendering of the byte budget for notices ("128 MB"). */ +export function previewByteBudgetLabel(): string { + return `${Math.round(MAX_SESSION_PREVIEW_BYTES / (1024 * 1024))} MB`; +} diff --git a/src/orchestrator/session-store.ts b/src/orchestrator/session-store.ts index 74998457d..23d20075e 100644 --- a/src/orchestrator/session-store.ts +++ b/src/orchestrator/session-store.ts @@ -51,6 +51,11 @@ interface Entry { * Unused for shared-scope keys (a shared session legitimately spans * workflows); preserved on legacy entries so nothing is rewritten. */ u?: string; + /** #1516 — cumulative AUTOMATIC-preview ledger for THIS session id: how many + * run-completion previews (and fetched bytes) the conversation already + * carries. Persisted so an orchestrator restart does not reset the budget + * of a provider conversation that lives on and keeps every inline image. */ + pl?: { i: number; b: number }; } interface StoreFileV2 { @@ -171,7 +176,7 @@ export class SessionStore { dirty = true; continue; } - const e = v as { s?: unknown; t?: unknown; u?: unknown }; + const e = v as { s?: unknown; t?: unknown; u?: unknown; pl?: unknown }; if (typeof e.s !== "string") { dirty = true; continue; @@ -188,6 +193,21 @@ export class SessionStore { } const entry: Entry = { s: e.s, t }; if (typeof e.u === "string") entry.u = e.u; + // #1516 — keep a well-formed preview ledger; a malformed one is dropped + // (the budget then starts un-spent, the safe direction: under-bounding + // a conversation is the failure mode this ledger exists to prevent, + // and a fresh-zero ledger can never over-claim delivered media). + if (e.pl && typeof e.pl === "object" && !Array.isArray(e.pl)) { + const pl = e.pl as { i?: unknown; b?: unknown }; + if ( + typeof pl.i === "number" && Number.isFinite(pl.i) && pl.i >= 0 && + typeof pl.b === "number" && Number.isFinite(pl.b) && pl.b >= 0 + ) { + entry.pl = { i: pl.i, b: pl.b }; + } else { + dirty = true; + } + } out[k] = entry; } return out; @@ -447,10 +467,41 @@ export class SessionStore { } const entry: Entry = { s: sessionId, t: this.now() }; if (u) entry.u = u; + // #1516 — the preview ledger is a property OF THE CONVERSATION: it carries + // over only when the session id is unchanged, and a fresh conversation + // provably starts at zero (its rollout holds no previews yet). + if (existing?.s === sessionId && existing.pl) entry.pl = existing.pl; this.sessions[key] = entry; return this.flush(); } + /** #1516 — the durable automatic-preview ledger for a key's current session, + * joined with the session id it was recorded against so a caller can adopt + * it only for that exact conversation. */ + previewLedger(key: string): { sid: string; images: number; bytes: number } | undefined { + const e = this.sessions[key]; + if (!e?.pl) return undefined; + return { sid: e.s, images: e.pl.i, bytes: e.pl.b }; + } + + /** #1516 — record (and persist) the automatic-preview ledger for a session. + * REFUSES when the stored session id is not the one the ledger describes: + * writing it anyway would either un-bound the conversation actually on disk + * or saddle a fresh one with media cost it never incurred — both silent + * corruptions of a safety bound. Returns whether the state reached disk. */ + setPreviewLedger( + key: string, + sessionId: string, + ledger: { images: number; bytes: number }, + ): boolean { + const e = this.sessions[key]; + if (!e || e.s !== sessionId) return false; + if (e.pl?.i === ledger.images && e.pl?.b === ledger.bytes) return this.settled(); + e.pl = { i: ledger.images, b: ledger.bytes }; + e.t = this.now(); + return this.flush(); + } + /** Forget a key's session — called on a deliberate NEW chat, so the disk * fallback never resurrects a conversation the user reset. Returns whether * the clear is DURABLE: false means the in-memory entry is gone (this