diff --git a/src/agent.ts b/src/agent.ts index 2c52c5e..acdd707 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -24,6 +24,7 @@ import { buildEffectiveSystemPrompt, composeAppendedSystemPrompt } from "./instr import { promptWithTextAttachments } from "./text-attachment.js"; import type { LoadedTextAttachment } from "./text-attachment.js"; import { compactMessages, buildCompactedTranscript } from "./compaction.js"; +import { ObservationPack } from "./observation-pack.js"; import type { LoadedImage } from "./image-input.js"; import type { TurnImageCollector } from "./turn-checkpoint.js"; import type { BottleneckCollector } from "./run-bottleneck.js"; @@ -388,6 +389,11 @@ export async function runAgent( // Provider stream: the network `streamChat` by default, or a deterministic // scripted provider for task-fixture replay (#224). const stream = opts.streamProvider ?? streamChat; + // ObservationPack (SoL-Pi): archives big tool results to a per-session spill + // file on arrival and substitutes them in the live transcript once they are + // about to be sent for the third time. The on-disk JSONL stays full-fidelity + // (onMessage appends before any rewrite); only the live array is rewritten. + const observationPack = new ObservationPack({ sessionId: opts.sessionId }); const messages: SessionMessage[] = [...existingMessages]; @@ -628,6 +634,12 @@ export async function runAgent( }); } + // ObservationPack rewrite point: after compaction (so identities are checked + // against the current array) and before this round's provider request. A + // spilled result about to be sent for the third time is substituted with + // marker + excerpt + handle; the most recent two tool messages are exempt. + observationPack.beforeProviderRequest(messages); + let assistantText = ""; let assistantToolCalls: Array<{ id: string; name: string; arguments: string }> = []; @@ -993,11 +1005,16 @@ export async function runAgent( bump(toolCalls, tc.name); if (result.isError) bump(toolFailures, tc.name); + // Spill on arrival, before the result joins the live array: the full + // content lands in the per-session spill file first, then the full result + // is pushed and persisted (JSONL stays full-fidelity) regardless. + observationPack.onToolResult(result.content, tc.id); const toolMsg: SessionMessage = { role: "tool", content: result.content, tool_call_id: tc.id, }; + observationPack.bindToolMessage(tc.id, toolMsg); messages.push(toolMsg); opts.onMessage(toolMsg); } diff --git a/src/observation-pack.ts b/src/observation-pack.ts new file mode 100644 index 0000000..ab47983 --- /dev/null +++ b/src/observation-pack.ts @@ -0,0 +1,294 @@ +// ObservationPack (SoL-Pi): big tool results are archived to a per-session +// spill file on arrival, ride in full for the next two provider requests (the +// model's immediate use is unharmed), and are then substituted in the live +// in-memory transcript with a marker + bounded head/tail excerpt + a retrieval +// handle. The on-disk session JSONL is never touched: messages are appended +// full-fidelity at push time, before any rewrite, and the rewrite replaces only +// the live array entry. One rewrite per result, guarded by an idempotence +// marker; the provider cache prefix busts once per spilled result — the +// accepted trade against carrying the full body forever. + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { SessionMessage } from "./session.js"; +import { safeByteCutEnd, safeByteTailStart } from "./text-cut.js"; + +// First line of every substitute; doubles as the idempotence marker. +export const SPILL_MARKER_PREFIX = "⤷ spilled tool result ("; + +// Default spill threshold: results larger than this many UTF-8 bytes spill. +export const DEFAULT_SPILL_BYTES = 10240; + +// Excerpt budget: complete head lines and complete tail lines, ~500 bytes each. +const EXCERPT_HEAD_BYTES = 500; +const EXCERPT_TAIL_BYTES = 500; + +// A spilled result rides in full for this many provider sends, then is +// substituted on the next one (its third send). +const FULL_SENDS = 2; + +export function spillThresholdBytes(env: NodeJS.ProcessEnv = process.env): number { + const raw = env.OMC_SPILL_BYTES; + if (raw === undefined) return DEFAULT_SPILL_BYTES; + const n = Number.parseInt(raw, 10); + return Number.isFinite(n) && n > 0 ? n : DEFAULT_SPILL_BYTES; +} + +// The session store root. Mirrors SessionStore's HOME resolution so spill +// directories sit beside the session JSONL they belong to. +export function sessionsBaseDir(env: NodeJS.ProcessEnv = process.env): string { + return path.join(env.HOME ?? os.homedir(), ".oh-my-cli", "sessions"); +} + +export function spillPathFor(sessionId: string, seq: number, baseDir: string = sessionsBaseDir()): string { + return path.join(baseDir, `${sessionId}.spill`, `${seq}.log`); +} + +// Write the full tool result to its spill file and return the absolute path. +export function writeSpillFile( + sessionId: string, + seq: number, + content: string, + baseDir: string = sessionsBaseDir(), +): string { + const fp = spillPathFor(sessionId, seq, baseDir); + fs.mkdirSync(path.dirname(fp), { recursive: true }); + fs.writeFileSync(fp, content, "utf-8"); + return fp; +} + +// Complete lines from the head of `content` within `budgetBytes`. Always keeps +// at least one line; a single line over budget is cut surrogate-safely by bytes. +function headExcerpt(content: string, budgetBytes: number): string { + const out: string[] = []; + let used = 0; + for (const line of content.split("\n")) { + const len = Buffer.byteLength(line, "utf8"); + if (len > budgetBytes) { + out.push(line.slice(0, safeByteCutEnd(line, budgetBytes))); + break; + } + if (out.length > 0 && used + len + 1 > budgetBytes) break; + out.push(line); + used += len + 1; + } + return out.join("\n"); +} + +// Complete lines from the tail of `content` within `budgetBytes`, in original +// order. Always keeps at least one line; a single line over budget keeps its +// trailing bytes, cut surrogate-safely. +function tailExcerpt(content: string, budgetBytes: number): string { + const lines = content.split("\n"); + const out: string[] = []; + let used = 0; + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + const len = Buffer.byteLength(line, "utf8"); + if (len > budgetBytes) { + out.unshift(line.slice(safeByteTailStart(line, budgetBytes))); + break; + } + if (out.length > 0 && used + len + 1 > budgetBytes) break; + out.unshift(line); + used += len + 1; + } + return out.join("\n"); +} + +// ~1 KB excerpt: complete head lines (~500 B) + complete tail lines (~500 B). +export function buildSpillExcerpt( + content: string, + headBytes: number = EXCERPT_HEAD_BYTES, + tailBytes: number = EXCERPT_TAIL_BYTES, +): string { + return `${headExcerpt(content, headBytes)}\n… [middle elided] …\n${tailExcerpt(content, tailBytes)}`; +} + +// The substitute that replaces a spilled result in the live transcript: marker +// line, bounded excerpt, retrieval handle. +export function buildSpillSubstitute( + originalBytes: number, + toolCallId: string, + spillPath: string, + content: string, +): string { + return [ + `⤷ spilled tool result (${originalBytes} bytes, tool_call_id ${toolCallId})`, + buildSpillExcerpt(content), + `full output: ${spillPath} — read it with the read tool (offset/limit) or grep it`, + ].join("\n"); +} + +// Resolve an absolute spill-file path for the `read` tool, or null when the +// path is not exactly /.spill/. The workspace path +// guard otherwise confines reads to the workspace; spill files are the one +// explicitly allowed exception so a seat can page its own archived output. +export function resolveSpillReadPath(p: string, baseDir: string = sessionsBaseDir()): string | null { + if (!path.isAbsolute(p)) return null; + const resolved = path.resolve(p); + const spillDir = path.dirname(resolved); + if (path.dirname(spillDir) !== path.resolve(baseDir)) return null; + if (!path.basename(spillDir).endsWith(".spill")) return null; + return resolved; +} + +// Opt-in debug instrumentation (OMC_SPILL_DEBUG=1): append one JSON line per +// spill/rewrite event to .spill/events.log. Off by default; used to +// prove rollout behavior (what a given provider request carried) without +// touching the transcript. +export function spillDebugLog( + sessionId: string, + event: Record, + baseDir: string = sessionsBaseDir(), + env: NodeJS.ProcessEnv = process.env, +): void { + if (!env.OMC_SPILL_DEBUG) return; + try { + const dir = path.join(baseDir, `${sessionId}.spill`); + fs.mkdirSync(dir, { recursive: true }); + fs.appendFileSync(path.join(dir, "events.log"), JSON.stringify(event) + "\n", "utf-8"); + } catch { + /* best-effort debug log */ + } +} + +interface SpillEntry { + toolCallId: string; + spillPath: string; + // Byte size of the full result at arrival, before any harness truncation. + originalBytes: number; + // Provider sends that have already carried this message. + sends: number; + rewritten: boolean; + // Identity of the live-array message object, bound when the (possibly + // truncated) result message is pushed; undefined until then. + msg?: SessionMessage; + // False once compaction has dropped the message from the live window. + live: boolean; +} + +export interface ObservationPackOptions { + sessionId: string; + thresholdBytes?: number; + baseDir?: string; +} + +// Per-run tracker. Spills big tool results on arrival and rewrites them in the +// live messages array once they are about to be sent for the third time. +export class ObservationPack { + private readonly sessionId: string; + private readonly thresholdBytes: number; + private readonly baseDir: string; + private entries: SpillEntry[] = []; + private seq = 0; + + constructor(opts: ObservationPackOptions) { + this.sessionId = opts.sessionId; + this.thresholdBytes = opts.thresholdBytes ?? spillThresholdBytes(); + this.baseDir = opts.baseDir ?? sessionsBaseDir(); + } + + // Called when a tool result arrives, BEFORE any harness truncation: the full + // original content is archived to the spill file so nothing is discarded. + // Results over the threshold are tracked; smaller ones never are. Spilling is + // best-effort: a filesystem failure never breaks the run. + onToolResult(content: string, toolCallId: string): void { + const seq = this.seq++; + if (typeof content !== "string") return; + const originalBytes = Buffer.byteLength(content, "utf8"); + if (originalBytes <= this.thresholdBytes) return; + let spillPath: string; + try { + spillPath = writeSpillFile(this.sessionId, seq, content, this.baseDir); + } catch { + return; + } + this.entries.push({ + toolCallId, + spillPath, + originalBytes, + sends: 0, + rewritten: false, + live: true, + }); + spillDebugLog( + this.sessionId, + { event: "spill", seq, toolCallId, originalBytes, spillPath }, + this.baseDir, + ); + } + + // Bind a spilled result to its live-array message object when the result is + // pushed. The message content may already be truncated by the harness; the + // spill file (and originalBytes) always hold the full arrival content. + bindToolMessage(toolCallId: string, msg: SessionMessage): void { + for (let i = this.entries.length - 1; i >= 0; i--) { + const entry = this.entries[i]; + if (entry.live && entry.msg === undefined && entry.toolCallId === toolCallId) { + entry.msg = msg; + return; + } + } + } + + // Called once per round at the round boundary, before the provider request + // is sent. A tracked result that has already ridden in full for FULL_SENDS + // sends is substituted now — its third send carries marker + excerpt + + // handle. One rewrite per result (idempotence marker + flag), never for the + // most recent two tool messages, never for results at or under the threshold + // (they are not tracked at all). + beforeProviderRequest(messages: SessionMessage[]): void { + if (this.entries.length === 0) return; + const toolIdx: number[] = []; + for (let i = 0; i < messages.length; i++) { + if (messages[i].role === "tool") toolIdx.push(i); + } + const protectedIdx = new Set(toolIdx.slice(-2)); + for (const entry of this.entries) { + if (entry.rewritten || !entry.live || entry.msg === undefined) continue; + const idx = messages.indexOf(entry.msg); + if (idx === -1) { + // Compaction replaced the live window; the spilled message is gone from + // it and compacts as before. Stop tracking the stale identity. + entry.live = false; + continue; + } + if (entry.sends < FULL_SENDS) { + entry.sends++; + continue; + } + if (protectedIdx.has(idx)) continue; + const current = typeof entry.msg.content === "string" ? entry.msg.content : ""; + if (current.startsWith(SPILL_MARKER_PREFIX)) { + entry.rewritten = true; + continue; + } + // Replace the array entry rather than mutating in place: consumers that + // already hold the original object (the append-only session log wrote it + // at push time) keep the full content. The excerpt is taken from the live + // body: the harness truncation keeps the original head and tail, so the + // excerpt's complete head/tail lines match the full result either way. + const rewritten: SessionMessage = { + ...entry.msg, + content: buildSpillSubstitute(entry.originalBytes, entry.toolCallId, entry.spillPath, current), + }; + messages[idx] = rewritten; + entry.msg = rewritten; + entry.rewritten = true; + spillDebugLog( + this.sessionId, + { + event: "rewrite", + toolCallId: entry.toolCallId, + originalBytes: entry.originalBytes, + substituteBytes: Buffer.byteLength(rewritten.content as string, "utf8"), + spillPath: entry.spillPath, + sends: entry.sends, + }, + this.baseDir, + ); + } + } +} diff --git a/src/tools.ts b/src/tools.ts index 3d0bbb1..d4f5806 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -11,6 +11,7 @@ import { formatGrepResult, } from "./discovery.js"; import { createCappedUtf8Decoder, type OutputByteCapState } from "./utf8-cap.js"; +import { resolveSpillReadPath } from "./observation-pack.js"; export interface ToolResult { content: string; @@ -87,12 +88,14 @@ export function createTools(): ToolDef[] { return [ { name: "read", - description: "Read a workspace-relative file with optional line offset and limit", + description: + "Read a workspace-relative file with optional line offset and limit. " + + "Also reads absolute spill-file paths under ~/.oh-my-cli/sessions/*.spill/ (archived tool output).", parameters: ReadParams, jsonSchema: { type: "object", properties: { - path: { type: "string", description: "Workspace-relative file path" }, + path: { type: "string", description: "Workspace-relative file path, or an absolute spill-file path under ~/.oh-my-cli/sessions/*.spill/" }, offset: { type: "number", description: "0-based line offset" }, limit: { type: "number", description: "Max lines to return" }, }, @@ -102,7 +105,9 @@ export function createTools(): ToolDef[] { execute: async (args, workspace) => { const { path: relPath, offset, limit } = ReadParams.parse(args); const fs = await import("node:fs"); - const absPath = workspace.resolveSafe(relPath); + // Spill files (ObservationPack archives) are the one allowed exception + // to workspace confinement; everything else resolves as before. + const absPath = resolveSpillReadPath(relPath) ?? workspace.resolveSafe(relPath); const content = fs.readFileSync(absPath, "utf-8"); if (offset !== undefined || limit !== undefined) { const lines = content.split("\n"); diff --git a/tests/unit/observation-pack.test.ts b/tests/unit/observation-pack.test.ts new file mode 100644 index 0000000..a4a29e8 --- /dev/null +++ b/tests/unit/observation-pack.test.ts @@ -0,0 +1,449 @@ +import { describe, it, expect, beforeEach, afterEach, afterAll } from "vitest"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { runAgent } from "../../src/agent.js"; +import type { AgentSink } from "../../src/agent.js"; +import { Workspace } from "../../src/workspace.js"; +import type { StreamProvider, StreamEvent } from "../../src/provider.js"; +import type { SessionMessage } from "../../src/session.js"; +import { createTools } from "../../src/tools.js"; +import { + ObservationPack, + SPILL_MARKER_PREFIX, + DEFAULT_SPILL_BYTES, + spillThresholdBytes, + sessionsBaseDir, + spillPathFor, + writeSpillFile, + buildSpillExcerpt, + buildSpillSubstitute, + resolveSpillReadPath, +} from "../../src/observation-pack.js"; + +const tmpDirs: string[] = []; +afterAll(() => { + for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true }); +}); + +function mkTmp(prefix: string): string { + const d = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tmpDirs.push(d); + return d; +} + +function makeRepo(): string { + const repo = mkTmp("omc-obspack-"); + execFileSync("git", ["init", "-q", "-b", "main", repo], { stdio: ["ignore", "pipe", "ignore"] }); + execFileSync("git", ["-C", repo, "config", "user.email", "test@example.com"], { stdio: ["ignore", "pipe", "ignore"] }); + execFileSync("git", ["-C", repo, "config", "user.name", "Test"], { stdio: ["ignore", "pipe", "ignore"] }); + return repo; +} + +function silentSink(): AgentSink { + return { + assistantDelta: () => {}, + assistantTurn: () => {}, + toolStart: () => {}, + toolResult: () => {}, + providerError: () => {}, + usage: () => {}, + retry: () => {}, + }; +} + +const baseConfig = { + apiKey: "test-key", + baseUrl: "https://example.com/v1", + model: "test-model", +}; + +// A big tool result: > DEFAULT_SPILL_BYTES of numbered, complete lines. +function bigContent(lines = 400): string { + let out = ""; + for (let i = 0; i < lines; i++) { + out += `line ${String(i).padStart(4, "0")}: ${"x".repeat(60)}\n`; + } + return out.trimEnd(); +} + +describe("observation-pack config and paths", () => { + it("defaults the spill threshold to 10240 and honors OMC_SPILL_BYTES", () => { + expect(DEFAULT_SPILL_BYTES).toBe(10240); + expect(spillThresholdBytes({})).toBe(10240); + expect(spillThresholdBytes({ OMC_SPILL_BYTES: "2048" })).toBe(2048); + expect(spillThresholdBytes({ OMC_SPILL_BYTES: "junk" })).toBe(10240); + expect(spillThresholdBytes({ OMC_SPILL_BYTES: "-5" })).toBe(10240); + }); + + it("builds the spill path under /.spill/.log", () => { + const base = path.join("/tmp", "sessions"); + expect(spillPathFor("abc", 3, base)).toBe( + path.join(base, "abc.spill", "3.log"), + ); + }); + + it("resolves the sessions base dir from HOME", () => { + expect(sessionsBaseDir({ HOME: "/tmp/fakehome" })).toBe( + path.join("/tmp/fakehome", ".oh-my-cli", "sessions"), + ); + }); +}); + +describe("writeSpillFile", () => { + it("writes the full content to the spill file", () => { + const base = mkTmp("omc-spill-"); + const content = bigContent(); + const fp = writeSpillFile("sess-1", 0, content, base); + expect(fp).toBe(spillPathFor("sess-1", 0, base)); + expect(fs.readFileSync(fp, "utf-8")).toBe(content); + }); +}); + +describe("buildSpillExcerpt", () => { + it("keeps complete head lines and complete tail lines within budget", () => { + const content = bigContent(100); + const excerpt = buildSpillExcerpt(content, 500, 500); + const [head, tail] = excerpt.split("\n… [middle elided] …\n"); + expect(excerpt).toContain("… [middle elided] …"); + // Head lines are the complete opening lines. + expect(head.startsWith("line 0000:")).toBe(true); + for (const line of head.split("\n")) { + expect(content.split("\n")).toContain(line); + } + // Tail lines are the complete closing lines. + expect(tail.endsWith("line 0099: " + "x".repeat(60))).toBe(true); + for (const line of tail.split("\n")) { + expect(content.split("\n")).toContain(line); + } + // ~500 B each side, bounded well under the original. + expect(Buffer.byteLength(head, "utf8")).toBeLessThanOrEqual(600); + expect(Buffer.byteLength(tail, "utf8")).toBeLessThanOrEqual(600); + expect(Buffer.byteLength(excerpt, "utf8")).toBeLessThan(Buffer.byteLength(content, "utf8") / 4); + }); + + it("byte-cuts a single over-long line instead of splitting it arbitrarily", () => { + const content = "z".repeat(5000); + const excerpt = buildSpillExcerpt(content, 500, 500); + expect(excerpt).toContain("… [middle elided] …"); + const [head, tail] = excerpt.split("\n… [middle elided] …\n"); + expect(head).toBe("z".repeat(500)); + expect(tail).toBe("z".repeat(500)); + }); +}); + +describe("buildSpillSubstitute", () => { + it("emits marker line, excerpt, and handle in the exact shape", () => { + const content = bigContent(100); + const sub = buildSpillSubstitute(7100, "call_42", "/abs/sess.spill/3.log", content); + const lines = sub.split("\n"); + expect(lines[0]).toBe("⤷ spilled tool result (7100 bytes, tool_call_id call_42)"); + expect(sub.startsWith(SPILL_MARKER_PREFIX)).toBe(true); + expect(lines[lines.length - 1]).toBe( + "full output: /abs/sess.spill/3.log — read it with the read tool (offset/limit) or grep it", + ); + expect(sub).toContain("… [middle elided] …"); + }); +}); + +describe("resolveSpillReadPath", () => { + it("accepts absolute paths under /*.spill/ and rejects everything else", () => { + const base = mkTmp("omc-spillread-"); + const ok = path.join(base, "s1.spill", "0.log"); + expect(resolveSpillReadPath(ok, base)).toBe(ok); + // Not a .spill directory. + expect(resolveSpillReadPath(path.join(base, "s1.jsonl"), base)).toBeNull(); + expect(resolveSpillReadPath(path.join(base, "other", "0.log"), base)).toBeNull(); + // Deeper nesting is not a spill file. + expect( + resolveSpillReadPath(path.join(base, "s1.spill", "nested", "0.log"), base), + ).toBeNull(); + // Relative paths are left to the workspace resolver. + expect(resolveSpillReadPath("s1.spill/0.log", base)).toBeNull(); + // Outside the sessions dir entirely. + expect(resolveSpillReadPath("/etc/passwd", base)).toBeNull(); + }); +}); + +describe("ObservationPack tracker", () => { + it("spills big results on arrival and rewrites at exactly the third send, idempotently", () => { + const base = mkTmp("omc-pack-"); + const pack = new ObservationPack({ sessionId: "s", thresholdBytes: 1024, baseDir: base }); + const content = bigContent(100); // ~7 KB > 1 KiB threshold + const msg: SessionMessage = { role: "tool", content, tool_call_id: "c1" }; + pack.onToolResult(content, "c1"); + pack.bindToolMessage("c1", msg); + + // Spill file exists with the full content. + const spillDir = path.join(base, "s.spill"); + const files = fs.readdirSync(spillDir); + expect(files).toEqual(["0.log"]); + expect(fs.readFileSync(path.join(spillDir, "0.log"), "utf-8")).toBe(content); + + const messages: SessionMessage[] = [ + { role: "user", content: "go" }, + { role: "assistant", content: null, tool_calls: [{ id: "c1", type: "function", function: { name: "read", arguments: "{}" } }] }, + msg, + // Two newer small tool results so the big one is not among the most + // recent two tool messages (those are never rewritten). + { role: "tool", content: "ok", tool_call_id: "c2" }, + { role: "tool", content: "ok", tool_call_id: "c3" }, + ]; + + // Sends 1 and 2 carry the full body. + pack.beforeProviderRequest(messages); + expect(messages[2].content).toBe(content); + pack.beforeProviderRequest(messages); + expect(messages[2].content).toBe(content); + + // Third send: substituted. + pack.beforeProviderRequest(messages); + const sub = messages[2].content as string; + expect(sub.startsWith(SPILL_MARKER_PREFIX)).toBe(true); + expect(sub).toContain(`tool_call_id c1)`); + expect(sub).toContain(`(${Buffer.byteLength(content, "utf8")} bytes`); + expect(sub).toContain(`full output: ${path.join(spillDir, "0.log")} — read it with the read tool (offset/limit) or grep it`); + expect(sub).not.toBe(content); + + // The rewrite replaced the array entry instead of mutating the persisted object. + expect(msg.content).toBe(content); + + // Idempotent: further sends keep the identical substitute. + const afterThird = messages[2]; + pack.beforeProviderRequest(messages); + pack.beforeProviderRequest(messages); + expect(messages[2]).toBe(afterThird); + expect(fs.readdirSync(spillDir)).toEqual(["0.log"]); + }); + + it("never rewrites results at or under the threshold", () => { + const base = mkTmp("omc-pack-small-"); + const pack = new ObservationPack({ sessionId: "s", thresholdBytes: 1024, baseDir: base }); + const msg: SessionMessage = { role: "tool", content: "small result", tool_call_id: "c1" }; + pack.onToolResult("small result", "c1"); + pack.bindToolMessage("c1", msg); + const messages: SessionMessage[] = [{ role: "user", content: "go" }, msg]; + for (let i = 0; i < 5; i++) pack.beforeProviderRequest(messages); + expect(messages[1].content).toBe("small result"); + expect(fs.existsSync(path.join(base, "s.spill"))).toBe(false); + }); + + it("never rewrites the most recent two tool messages", () => { + const base = mkTmp("omc-pack-recent-"); + const pack = new ObservationPack({ sessionId: "s", thresholdBytes: 1024, baseDir: base }); + const big1: SessionMessage = { role: "tool", content: bigContent(100), tool_call_id: "c1" }; + const big2: SessionMessage = { role: "tool", content: bigContent(100), tool_call_id: "c2" }; + const big3: SessionMessage = { role: "tool", content: bigContent(100), tool_call_id: "c3" }; + pack.onToolResult(big1.content as string, "c1"); + pack.onToolResult(big2.content as string, "c2"); + pack.onToolResult(big3.content as string, "c3"); + pack.bindToolMessage("c1", big1); + pack.bindToolMessage("c2", big2); + pack.bindToolMessage("c3", big3); + const messages: SessionMessage[] = [{ role: "user", content: "go" }, big1, big2, big3]; + // Two sends in full for all three. + pack.beforeProviderRequest(messages); + pack.beforeProviderRequest(messages); + expect(messages[1].content).toBe(big1.content); + expect(messages[2].content).toBe(big2.content); + expect(messages[3].content).toBe(big3.content); + // Third send: c1 (third-from-last) rewrites; c2 and c3 are the most recent + // two tool messages and stay full until another tool message arrives. + pack.beforeProviderRequest(messages); + expect((messages[1].content as string).startsWith(SPILL_MARKER_PREFIX)).toBe(true); + expect(messages[2].content).toBe(big2.content); + expect(messages[3].content).toBe(big3.content); + }); + + it("stops tracking messages that compaction removed from the live window", () => { + const base = mkTmp("omc-pack-compact-"); + const pack = new ObservationPack({ sessionId: "s", thresholdBytes: 1024, baseDir: base }); + const big: SessionMessage = { role: "tool", content: bigContent(100), tool_call_id: "c1" }; + pack.onToolResult(big.content as string, "c1"); + pack.bindToolMessage("c1", big); + const messages: SessionMessage[] = [{ role: "user", content: "go" }, big]; + pack.beforeProviderRequest(messages); + // Compaction replaces the window wholesale. + messages.length = 0; + messages.push({ role: "user", content: "summary" }); + // No crash, no rewrite of a message that is no longer live. + pack.beforeProviderRequest(messages); + pack.beforeProviderRequest(messages); + pack.beforeProviderRequest(messages); + expect(messages.length).toBe(1); + }); +}); + +describe("runAgent ObservationPack integration", () => { + let savedHome: string | undefined; + let fakeHome: string; + beforeEach(() => { + savedHome = process.env.HOME; + fakeHome = mkTmp("omc-obshome-"); + process.env.HOME = fakeHome; + }); + afterEach(() => { + if (savedHome === undefined) delete process.env.HOME; + else process.env.HOME = savedHome; + }); + + it("spills a big result, carries it full for two sends, substitutes on the third, and keeps the JSONL full", async () => { + const repo = makeRepo(); + // ~20 KB: over the 10240-byte spill threshold, under the 24000-char harness + // truncation so the result rides in full until the rewrite. + const big = bigContent(280); + fs.writeFileSync(path.join(repo, "big.txt"), big); + fs.writeFileSync(path.join(repo, "a.txt"), "tiny\n"); + + // Provider script: read big.txt, then two small reads to age the big + // result, then finish. Capture the messages each provider call receives. + const sentToolContents: (string | null | undefined)[][] = []; + let round = 0; + const provider: StreamProvider = async function* (_config, messages) { + round++; + sentToolContents.push(messages.filter((m) => m.role === "tool").map((m) => m.content)); + if (round === 1) { + yield { type: "tool_call", id: "c1", name: "read", arguments: JSON.stringify({ path: "big.txt" }) } as StreamEvent; + return; + } + if (round === 2) { + yield { type: "tool_call", id: "c2", name: "read", arguments: JSON.stringify({ path: "a.txt" }) } as StreamEvent; + return; + } + if (round === 3) { + yield { type: "tool_call", id: "c3", name: "read", arguments: JSON.stringify({ path: "a.txt" }) } as StreamEvent; + return; + } + yield { type: "text", delta: "done" } as StreamEvent; + }; + + const persisted: SessionMessage[] = []; + const result = await runAgent("read the files", [], { + config: baseConfig, + workspace: new Workspace(repo), + approvalMode: "yolo", + sessionId: "obs-int", + onMessage: (m) => persisted.push(m), + sink: silentSink(), + streamProvider: provider, + }); + + expect(result.ok).toBe(true); + expect(round).toBe(4); + + // The spill file exists with the full content. + const spillDir = path.join(fakeHome, ".oh-my-cli", "sessions", "obs-int.spill"); + const spillFiles = fs.readdirSync(spillDir); + expect(spillFiles).toEqual(["0.log"]); + expect(fs.readFileSync(path.join(spillDir, "0.log"), "utf-8")).toBe(big); + + // Round 2 saw the big result for the first time: full. + expect(sentToolContents[1][0]).toBe(big); + // Round 3 (second send): still full. + expect(sentToolContents[2][0]).toBe(big); + // Round 4 (third send): the substitute — marker, excerpt, handle. + const third = sentToolContents[3][0] as string; + expect(third.startsWith(SPILL_MARKER_PREFIX)).toBe(true); + expect(third).toContain(`(${Buffer.byteLength(big, "utf8")} bytes, tool_call_id c1)`); + expect(third).toContain("… [middle elided] …"); + expect(third).toContain( + `full output: ${path.join(spillDir, "0.log")} — read it with the read tool (offset/limit) or grep it`, + ); + expect(Buffer.byteLength(third, "utf8")).toBeLessThan(2000); + // The small results were never rewritten. + expect(sentToolContents[3][1]).toBe("tiny\n"); + expect(sentToolContents[3][2]).toBe("tiny\n"); + + // The persisted transcript (session JSONL content) stays full-fidelity. + const persistedBig = persisted.find((m) => m.role === "tool" && m.tool_call_id === "c1"); + expect(persistedBig?.content).toBe(big); + }); + + it("respects OMC_SPILL_BYTES for the spill threshold", async () => { + process.env.OMC_SPILL_BYTES = "64"; + try { + const repo = makeRepo(); + fs.writeFileSync(path.join(repo, "a.txt"), "x".repeat(200)); + let round = 0; + const provider: StreamProvider = async function* () { + round++; + if (round === 1) { + yield { type: "tool_call", id: "c1", name: "read", arguments: JSON.stringify({ path: "a.txt" }) } as StreamEvent; + return; + } + yield { type: "text", delta: "done" } as StreamEvent; + }; + const result = await runAgent("read", [], { + config: baseConfig, + workspace: new Workspace(repo), + approvalMode: "yolo", + sessionId: "obs-env", + onMessage: () => {}, + sink: silentSink(), + streamProvider: provider, + }); + expect(result.ok).toBe(true); + const spillDir = path.join(fakeHome, ".oh-my-cli", "sessions", "obs-env.spill"); + expect(fs.readFileSync(path.join(spillDir, "0.log"), "utf-8")).toBe("x".repeat(200)); + } finally { + delete process.env.OMC_SPILL_BYTES; + } + }); +}); + +describe("read tool spill access", () => { + let savedHome: string | undefined; + let fakeHome: string; + beforeEach(() => { + savedHome = process.env.HOME; + fakeHome = mkTmp("omc-readhome-"); + process.env.HOME = fakeHome; + }); + afterEach(() => { + if (savedHome === undefined) delete process.env.HOME; + else process.env.HOME = savedHome; + }); + + function readTool() { + const tool = createTools().find((t) => t.name === "read"); + if (!tool) throw new Error("read tool missing"); + return tool; + } + + it("pages a spill file by offset/limit from an absolute path", async () => { + const base = sessionsBaseDir(); + const content = Array.from({ length: 50 }, (_, i) => `row ${i}`).join("\n"); + const spill = writeSpillFile("readsess", 0, content, base); + const repo = makeRepo(); + const ws = new Workspace(repo); + + const page = await readTool().execute({ path: spill, offset: 10, limit: 5 }, ws); + expect(page.content).toBe(["row 10", "row 11", "row 12", "row 13", "row 14"].join("\n")); + + const whole = await readTool().execute({ path: spill }, ws); + expect(whole.content).toBe(content); + }); + + it("still refuses absolute paths outside the spill directory", async () => { + const repo = makeRepo(); + const ws = new Workspace(repo); + const outside = path.join(fakeHome, ".oh-my-cli", "sessions", "s1.jsonl"); + fs.mkdirSync(path.dirname(outside), { recursive: true }); + fs.writeFileSync(outside, "{}\n"); + await expect(readTool().execute({ path: outside }, ws)).rejects.toThrow(/Path escape/); + await expect(readTool().execute({ path: "/etc/passwd" }, ws)).rejects.toThrow(/Path escape/); + }); + + it("grep and shell can reach a spill file by absolute path", async () => { + const base = sessionsBaseDir(); + const spill = writeSpillFile("grepsess", 0, "alpha\nNEEDLE-line\nomega\n", base); + const repo = makeRepo(); + const ws = new Workspace(repo); + const shell = createTools().find((t) => t.name === "shell"); + if (!shell) throw new Error("shell tool missing"); + const out = await shell.execute({ command: `grep -n NEEDLE "${spill}"` }, ws); + expect(out.isError).toBeFalsy(); + expect(out.content).toContain("2:NEEDLE-line"); + }); +});