diff --git a/packages/harness/src/context/build-context.ts b/packages/harness/src/context/build-context.ts index cc7deb4..e8edaaf 100644 --- a/packages/harness/src/context/build-context.ts +++ b/packages/harness/src/context/build-context.ts @@ -6,10 +6,26 @@ function text(value: unknown, fallback = "") { return typeof value === "string" ? value : fallback; } +/** + * Durable handoff text for resume. New completed entries carry validated + * rendered markdown; older entries already stored prose in the same field. + * Neither form requires reading any filesystem location. + */ +function resumeHandoffText(entry: SessionEntry): string | null { + const handoff = entry.payload.handoff; + if (typeof handoff === "string" && handoff.trim()) return handoff; + return null; +} + export function buildModelContext(entries: SessionEntry[]): ModelContextItem[] { const context: ModelContextItem[] = []; - const compaction = latestCompletedCompaction(entries); - const visibleEntries = compaction ? entries.slice(compaction.index + 1) : entries; + const completed = latestCompletedCompaction(entries); + const handoff = completed ? resumeHandoffText(completed.entry) : null; + // A corrupt/missing summary cannot authorize dropping any earlier context. + const compaction = handoff === null ? null : completed; + const visibleEntries = (compaction ? entries.slice(compaction.index + 1) : entries).filter( + (entry) => entry.payload.purpose !== "compaction", + ); const responsesByRun = new Map< string, import("../models/transport.ts").ProviderResponseMetadata @@ -24,10 +40,10 @@ export function buildModelContext(entries: SessionEntry[]): ModelContextItem[] { ); } } - if (compaction) { + if (compaction && handoff !== null) { context.push({ type: "user_message", - text: buildResumePrompt(text(compaction.entry.payload.handoffDirectory)), + text: buildResumePrompt(handoff), model: compaction.entry.payload.model as { connectionId: string; modelId: string }, reasoning: text(compaction.entry.payload.reasoning, "none"), }); diff --git a/packages/harness/src/context/compaction-handoff.test.ts b/packages/harness/src/context/compaction-handoff.test.ts new file mode 100644 index 0000000..3d1a234 --- /dev/null +++ b/packages/harness/src/context/compaction-handoff.test.ts @@ -0,0 +1,749 @@ +import { mkdir, mkdtemp, realpath, readFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import { describe, expect, test } from "vite-plus/test"; +import { + createOpenGuiHarness, + type DurableActor, + type ExecutionPolicy, + type ModelRequest, + type ModelStreamEvent, + type ModelTransport, + type SessionEntry, +} from "../index.ts"; +import type { ProviderResponseMetadata } from "../models/transport.ts"; +import { buildModelContext } from "./build-context.ts"; +import { MAX_HANDOFF_BYTES } from "./compaction.ts"; +import { SqliteSessionStore } from "../storage/sqlite-store.ts"; +import { FakeClock, SequenceIdGenerator } from "../test/index.ts"; + +async function temporaryDirectory() { + return mkdtemp(join(tmpdir(), "opengui-compaction-pra-")); +} + +function containsPath(root: string, candidate: string) { + const fromRoot = relative(root, candidate); + return ( + fromRoot === "" || + (!fromRoot.startsWith(`..${sep}`) && fromRoot !== ".." && !isAbsolute(fromRoot)) + ); +} + +/** Harmless local stand-in for Host path-grant enforcement: project-scoped, deny-by-default. */ +function projectScopedPolicy( + root: string, + input?: { revision?: number; revoked?: () => boolean }, +): ExecutionPolicy { + return { + restricted: true, + revision: input?.revision ?? 1, + shellAllowed: false, + async authorizePath(path, access, options = {}) { + void access; + void options; + if (input?.revoked?.()) return { allowed: false, reason: "outside_grants" }; + const target = resolve(path); + if (!containsPath(root, target)) return { allowed: false, reason: "outside_grants" }; + return { allowed: true, canonicalPath: target }; + }, + }; +} + +type HandoffMode = + | "valid" + | "prose" + | "empty" + | "oversized" + | "incomplete" + | "truncated" + | "compactionToolCall" + | "hangOnHandoff" + | "sessionGoal" + | "outsideTaskWrite"; + +const SUMMARY_RESPONSE: ProviderResponseMetadata = { + provider: "fixture", + api: "fixture", + model: "fake-model", + protocol: "openai-chat", + usage: { input: 100, output: 30, cacheRead: 0, cacheWrite: 0, total: 130 }, + stopReason: "stop", + cache: { generation: "fixture", readTokens: 0, writeTokens: 0 }, + timing: { startedAt: "2026-10-06T00:00:00.000Z", completedMs: 1, attempts: 1 }, +}; + +const SUMMARY = (goal: string) => ({ + goal, + currentState: "Fixture work is partially done.", + constraints: "Stay inside the fixture project.", + decisions: "Single summary response.", + blockers: "None.", + relevantFiles: ["README.md"], + nextSteps: ["Continue from the summary."], +}); + +/** + * Deterministic model fixture for the tool-free JSON handoff. Task turns answer + * text (long on the first turn so a small threshold triggers on the next + * prompt); compaction turns return one JSON response per mode. + */ +class JsonHandoffModel implements ModelTransport { + readonly requests: ModelRequest[] = []; + #taskTurns = 0; + constructor( + public mode: HandoffMode = "valid", + readonly goal = "Continue the fixture task.", + ) {} + + async *stream(request: ModelRequest, signal: AbortSignal): AsyncIterable { + if (signal.aborted) throw signal.reason; + this.requests.push(structuredClone(request)); + const last = request.context.at(-1); + const isHandoffTurn = + last?.type === "user_message" && last.text.includes("CONTEXT HANDOFF MODE"); + const first = request.context[0]; + const isResume = + first?.type === "user_message" && + typeof first.text === "string" && + first.text.includes("HISTORICAL CONTEXT"); + + if (isHandoffTurn) { + if (this.mode === "hangOnHandoff") { + await new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => reject(signal.reason ?? new DOMException("Aborted", "AbortError")), + { once: true }, + ); + }); + return; + } + switch (this.mode) { + case "prose": + yield { type: "text_delta", delta: "# Handoff\n\nJust continue the work.\n" }; + break; + case "empty": + yield { type: "text_delta", delta: " \n" }; + break; + case "oversized": + yield { + type: "text_delta", + delta: JSON.stringify({ ...SUMMARY(this.goal), decisions: "x".repeat(100_000) }), + }; + break; + case "compactionToolCall": + yield { + type: "tool_call", + id: "rogue-write", + name: "write", + input: { path: "should-never-exist.txt", content: "rogue" }, + }; + break; + case "sessionGoal": + yield { + type: "text_delta", + delta: JSON.stringify(SUMMARY(`Goal for ${request.identity?.sessionId}`)), + }; + break; + default: + yield { type: "text_delta", delta: JSON.stringify(SUMMARY(this.goal)) }; + break; + } + if (this.mode === "incomplete") return; + yield { + type: "completed", + response: { + ...SUMMARY_RESPONSE, + stopReason: this.mode === "truncated" ? "length" : "stop", + }, + }; + return; + } + + if (this.mode === "outsideTaskWrite" && this.#taskTurns === 0) { + this.#taskTurns += 1; + yield { + type: "tool_call", + id: "outside-write", + name: "write", + input: { path: join(request.projectDirectory, "..", "outside.txt"), content: "no" }, + }; + yield { type: "completed" }; + return; + } + this.#taskTurns += 1; + yield { + type: "text_delta", + delta: isResume + ? `Resumed: ${this.goal}` + : this.#taskTurns === 1 + ? `Long work state ${"x".repeat(1_000)}` + : "Task answer.", + }; + yield { type: "completed" }; + } +} + +const MEMBER: DurableActor = { type: "user", id: "member-1", displayName: "Member One" }; + +async function setupRestricted(input?: { + mode?: HandoffMode; + goal?: string; + contextWindowTokens?: number; + revoked?: () => boolean; + customInstructions?: (actor: DurableActor | undefined) => string | undefined; +}) { + const dataDirectory = await temporaryDirectory(); + const projectDirectory = join(dataDirectory, "project"); + const homeDirectory = join(dataDirectory, "home"); + await mkdir(projectDirectory, { recursive: true }); + await mkdir(homeDirectory, { recursive: true }); + const canonicalProject = await realpath(projectDirectory); + const model = new JsonHandoffModel(input?.mode ?? "valid", input?.goal); + const harness = createOpenGuiHarness({ + dataDirectory, + homeDirectory, + model, + clock: new FakeClock("2026-07-10T10:00:00.000Z"), + ids: new SequenceIdGenerator(), + compaction: { + contextWindowTokens: input?.contextWindowTokens ?? 10_000, + thresholdRatio: 0.7, + }, + resolveExecutionPolicy: async () => projectScopedPolicy(canonicalProject, input), + ...(input?.customInstructions + ? { + resolveCustomInstructions: async ({ actor }: { actor?: DurableActor }) => + input.customInstructions!(actor), + } + : {}), + }); + const session = await harness.createSession({ + projectDirectory, + model: { connectionId: "fake", modelId: "fake-model" }, + reasoning: "none", + }); + return { + dataDirectory, + projectDirectory, + homeDirectory, + canonicalProject, + model, + harness, + session, + }; +} + +async function drain(events: AsyncIterable) { + for await (const _event of events) { + // drain + } +} + +function handoffTurns(model: JsonHandoffModel) { + return model.requests.filter((request) => + request.context.some( + (item) => item.type === "user_message" && item.text.includes("CONTEXT HANDOFF MODE"), + ), + ); +} + +describe("compaction handoff (issue #150, Harness seam)", () => { + test("restricted manual compaction succeeds and the next turn continues the task", async () => { + const { harness, session, model } = await setupRestricted(); + try { + for await (const _event of session.run({ + text: "Do harmless fixture work", + actor: MEMBER, + skills: [], + })) { + // drain + } + await drain(session.compact(MEMBER)); + + const snapshot = await session.read(); + const compactions = snapshot.entries.filter((entry) => entry.kind === "compaction"); + expect(compactions.map((entry) => entry.payload.status)).toEqual(["started", "completed"]); + expect(compactions.at(-1)?.payload.handoff).toContain("Continue the fixture task."); + expect(compactions.at(-1)?.payload).not.toHaveProperty("handoffDirectory"); + expect(compactions.at(-1)?.payload).not.toHaveProperty("handoffPath"); + + // No normal Tool entries were executed for compaction storage. + expect( + snapshot.entries.filter( + (entry) => + (entry.kind === "tool_call" || entry.kind === "tool_result") && + entry.payload.purpose === "compaction", + ), + ).toHaveLength(0); + + await drain(session.run({ text: "Continue after handoff", actor: MEMBER })); + const resume = model.requests.find((request) => + request.context.some( + (item) => item.type === "user_message" && item.text.includes("HISTORICAL CONTEXT"), + ), + ); + expect(resume).toBeDefined(); + expect(JSON.stringify(model.requests)).not.toContain("HANDOFF.md"); + } finally { + await harness.close(); + } + }); + + test("restricted threshold compaction succeeds and answers from the injected summary", async () => { + const { harness, session, model } = await setupRestricted({ contextWindowTokens: 300 }); + try { + for await (const _event of session.run({ + text: "Start the long task", + actor: MEMBER, + skills: [], + })) { + // drain — long first answer pushes the next prompt over threshold + } + await drain(session.run({ text: "Continue the work", actor: MEMBER })); + + expect(handoffTurns(model)).toHaveLength(1); + expect(handoffTurns(model)[0]?.tools).toEqual([]); + const snapshot = await session.read(); + expect( + snapshot.entries + .filter((entry) => entry.kind === "compaction") + .map((entry) => entry.payload.status), + ).toEqual(["started", "completed"]); + expect(snapshot.entries.at(-1)?.kind).toBe("run_completed"); + } finally { + await harness.close(); + } + }); + + test.each(["incomplete", "truncated"] as const)( + "rejects a %s response even when the emitted JSON is syntactically valid", + async (mode) => { + const { harness, session } = await setupRestricted({ mode }); + try { + await drain(session.run({ text: "Prepare fixture work", actor: MEMBER, skills: [] })); + await expect(drain(session.compact(MEMBER))).rejects.toThrow( + "Compaction summary response was incomplete", + ); + expect( + (await session.read()).entries.some( + (entry) => entry.kind === "compaction" && entry.payload.status === "completed", + ), + ).toBe(false); + } finally { + await harness.close(); + } + }, + ); + + test("preserves compaction provider usage separately from task output", async () => { + const { harness, session } = await setupRestricted(); + try { + await drain(session.run({ text: "Prepare fixture work", actor: MEMBER, skills: [] })); + let liveDeltas = 0; + for await (const event of session.compact(MEMBER)) { + if (event.type === "assistant_delta" || event.type === "reasoning_delta") liveDeltas += 1; + } + const metadata = (await session.read()).entries.filter( + (entry) => entry.kind === "provider_response" && entry.payload.purpose === "compaction", + ); + expect(metadata).toHaveLength(1); + expect(metadata[0]?.payload.response).toEqual(SUMMARY_RESPONSE); + expect(liveDeltas).toBe(0); + } finally { + await harness.close(); + } + }); + + test("prose-only compaction responses are rejected without a completed entry", async () => { + const { harness, session } = await setupRestricted({ mode: "prose" }); + try { + for await (const _event of session.run({ text: "Do work", actor: MEMBER, skills: [] })) { + // drain + } + await expect(drain(session.compact(MEMBER))).rejects.toThrow( + "Compaction summary was invalid", + ); + const snapshot = await session.read(); + expect( + snapshot.entries + .filter((entry) => entry.kind === "compaction") + .map((entry) => entry.payload.status), + ).toEqual(["started"]); + expect(snapshot.entries.find((entry) => entry.kind === "run_failed")).toBeDefined(); + } finally { + await harness.close(); + } + }); + + test("empty compaction responses are rejected without a completed entry", async () => { + const { harness, session } = await setupRestricted({ mode: "empty" }); + try { + for await (const _event of session.run({ text: "Do work", actor: MEMBER, skills: [] })) { + // drain + } + await expect(drain(session.compact(MEMBER))).rejects.toThrow("Compaction summary was empty"); + const snapshot = await session.read(); + expect( + snapshot.entries + .filter((entry) => entry.kind === "compaction") + .map((entry) => entry.payload.status), + ).toEqual(["started"]); + } finally { + await harness.close(); + } + }); + + test("oversized compaction responses are rejected without truncation or completion", async () => { + expect(MAX_HANDOFF_BYTES).toBe(64 * 1024); + const { harness, session } = await setupRestricted({ mode: "oversized" }); + try { + for await (const _event of session.run({ text: "Do work", actor: MEMBER, skills: [] })) { + // drain + } + await expect(drain(session.compact(MEMBER))).rejects.toThrow( + "Compaction summary exceeds the size limit", + ); + const snapshot = await session.read(); + expect( + snapshot.entries + .filter((entry) => entry.kind === "compaction") + .map((entry) => entry.payload.status), + ).toEqual(["started"]); + } finally { + await harness.close(); + } + }); + + test("unexpected compaction tool calls are refused without execution", async () => { + const { harness, session, projectDirectory } = await setupRestricted({ + mode: "compactionToolCall", + }); + try { + for await (const _event of session.run({ text: "Do work", actor: MEMBER, skills: [] })) { + // drain + } + await expect(drain(session.compact(MEMBER))).rejects.toThrow( + "Compaction response must not call tools", + ); + await expect( + readFile(join(projectDirectory, "should-never-exist.txt"), "utf8"), + ).rejects.toThrow(); + const snapshot = await session.read(); + expect(snapshot.entries.filter((entry) => entry.kind === "tool_result")).toHaveLength(0); + expect( + snapshot.entries + .filter((entry) => entry.kind === "compaction") + .map((entry) => entry.payload.status), + ).toEqual(["started"]); + } finally { + await harness.close(); + } + }); + + test("injected durable-storage failure cannot create a false completed entry", async () => { + const { harness, session } = await setupRestricted(); + const storePrototype = SqliteSessionStore.prototype; + const originalDescriptor = Object.getOwnPropertyDescriptor(storePrototype, "appendEntry"); + if (!originalDescriptor?.value) throw new Error("appendEntry descriptor not found"); + const originalAppendEntry = originalDescriptor.value as typeof storePrototype.appendEntry; + storePrototype.appendEntry = async function ( + this: SqliteSessionStore, + sessionId: string, + kind: SessionEntry["kind"], + payload: Record, + now: string, + ) { + if (kind === "compaction" && payload.status === "completed") { + throw new Error("injected storage failure"); + } + return originalAppendEntry.call(this, sessionId, kind, payload, now); + }; + try { + for await (const _event of session.run({ text: "Do work", actor: MEMBER, skills: [] })) { + // drain + } + await expect(drain(session.compact(MEMBER))).rejects.toThrow("injected storage failure"); + const snapshot = await session.read(); + expect( + snapshot.entries + .filter((entry) => entry.kind === "compaction") + .map((entry) => entry.payload.status), + ).toEqual(["started"]); + // Accepted user intent survives the failed publish. + expect( + snapshot.entries + .filter((entry) => entry.kind === "user_message") + .map((entry) => entry.payload.text), + ).toContain("Do work"); + } finally { + Object.defineProperty(storePrototype, "appendEntry", originalDescriptor); + await harness.close(); + } + }); + + test("cancelling compaction records an aborted run without a completed entry", async () => { + const { harness, session } = await setupRestricted({ mode: "hangOnHandoff" }); + try { + for await (const _event of session.run({ text: "Do work", actor: MEMBER, skills: [] })) { + // drain + } + const iterator = session.compact(MEMBER)[Symbol.asyncIterator](); + await iterator.next(); + await session.abort(); + while (!(await iterator.next()).done) { + // drain the terminal run event; abort resolves without rethrowing + } + const snapshot = await session.read(); + expect(snapshot.entries.at(-1)?.kind).toBe("run_aborted"); + expect( + snapshot.entries + .filter((entry) => entry.kind === "compaction") + .map((entry) => entry.payload.status), + ).toEqual(["started"]); + } finally { + await harness.close(); + } + }); + + test("legacy prose handoffs resume without reading any temp folder", () => { + const model = { connectionId: "fake", modelId: "fake-model" }; + const entries = [ + { + id: "entry-1", + sessionId: "session-1", + sequence: 1, + kind: "user_message", + payload: { text: "Old work", model, reasoning: "none" }, + createdAt: "2026-07-10T10:00:00.000Z", + }, + { + id: "entry-2", + sessionId: "session-1", + sequence: 2, + kind: "compaction", + payload: { + runId: "run-1", + status: "completed", + handoffDirectory: "/tmp/gone/opengui/handoffs", + handoffPath: "/tmp/gone/opengui/handoffs/HANDOFF.md", + handoff: "# Old prose\n\n## Next steps\n1. Keep going.\n", + model, + reasoning: "none", + reason: "threshold", + }, + createdAt: "2026-07-10T10:00:01.000Z", + }, + ] as unknown as SessionEntry[]; + const context = buildModelContext(entries); + expect(context).toHaveLength(1); + const first = context[0]; + expect(first).toMatchObject({ + type: "user_message", + text: expect.stringContaining("HISTORICAL CONTEXT"), + }); + const firstText = first?.type === "user_message" ? first.text : ""; + expect(firstText).toContain("Keep going."); + expect(firstText).not.toContain("/tmp/gone"); + }); + + test("two concurrent sessions keep isolated handoffs", async () => { + const dataDirectory = await temporaryDirectory(); + const projectDirectory = join(dataDirectory, "project"); + const homeDirectory = join(dataDirectory, "home"); + await mkdir(projectDirectory, { recursive: true }); + await mkdir(homeDirectory, { recursive: true }); + const canonicalProject = await realpath(projectDirectory); + const model = new JsonHandoffModel("sessionGoal"); + const harness = createOpenGuiHarness({ + dataDirectory, + homeDirectory, + model, + clock: new FakeClock("2026-07-10T10:00:00.000Z"), + ids: new SequenceIdGenerator(), + compaction: { contextWindowTokens: 10_000 }, + resolveExecutionPolicy: async () => projectScopedPolicy(canonicalProject), + }); + try { + const first = await harness.createSession({ + projectDirectory, + model: { connectionId: "fake", modelId: "fake-model" }, + reasoning: "none", + }); + const second = await harness.createSession({ + projectDirectory, + model: { connectionId: "fake", modelId: "fake-model" }, + reasoning: "none", + }); + for await (const _event of first.run({ text: "First work", actor: MEMBER, skills: [] })) { + // drain + } + for await (const _event of second.run({ text: "Second work", actor: MEMBER, skills: [] })) { + // drain + } + await Promise.all([drain(first.compact(MEMBER)), drain(second.compact(MEMBER))]); + const firstHandoff = (await first.read()).entries.find( + (entry) => entry.kind === "compaction" && entry.payload.status === "completed", + )?.payload.handoff as string; + const secondHandoff = (await second.read()).entries.find( + (entry) => entry.kind === "compaction" && entry.payload.status === "completed", + )?.payload.handoff as string; + const firstId = (await first.read()).id; + const secondId = (await second.read()).id; + expect(firstHandoff).toContain(firstId); + expect(firstHandoff).not.toContain(secondId); + expect(secondHandoff).toContain(secondId); + expect(secondHandoff).not.toContain(firstId); + } finally { + await harness.close(); + } + }); + + test("grant revocation blocks publishing and restart resumes from durable history", async () => { + let revoked = false; + const { harness, session, dataDirectory } = await setupRestricted({ + revoked: () => revoked, + }); + try { + for await (const _event of session.run({ text: "Do work", actor: MEMBER, skills: [] })) { + // drain + } + revoked = true; + // Authorization failure aborts the compaction run without rethrowing; + // the denial is durable as run_failed and nothing is published. + await drain(session.compact(MEMBER)); + const afterRevoke = await session.read(); + // Revocation hits before any compaction entry is published. + expect(afterRevoke.entries.filter((entry) => entry.kind === "compaction")).toEqual([]); + expect(afterRevoke.entries.at(-1)).toMatchObject({ + kind: "run_failed", + payload: { error: expect.stringContaining("denied") }, + }); + const sessionId = (await session.read()).id; + await harness.close(); + + revoked = false; + const reopened = createOpenGuiHarness({ + dataDirectory, + homeDirectory: join(dataDirectory, "home"), + model: new JsonHandoffModel(), + clock: new FakeClock("2026-07-10T10:00:00.000Z"), + ids: new SequenceIdGenerator(100), + compaction: { contextWindowTokens: 10_000 }, + resolveExecutionPolicy: async () => + projectScopedPolicy(await realpath(join(dataDirectory, "project"))), + }); + try { + const same = await reopened.openSession(sessionId); + // Compaction never completed, so this turn runs without resume context. + for await (const _event of same.run({ text: "Retry after re-grant", actor: MEMBER })) { + // drain + } + expect( + (await same.read()).entries + .filter((entry) => entry.kind === "user_message") + .map((entry) => entry.payload.text), + ).toContain("Retry after re-grant"); + } finally { + await reopened.close(); + } + } finally { + await harness.close().catch(() => undefined); + } + }); + + test("restart after a completed compaction resumes from the durable summary", async () => { + const { harness, session, dataDirectory } = await setupRestricted(); + const sessionId = (await session.read()).id; + try { + for await (const _event of session.run({ text: "Do work", actor: MEMBER, skills: [] })) { + // drain + } + await drain(session.compact(MEMBER)); + } finally { + await harness.close(); + } + const reopenedModel = new JsonHandoffModel(); + const reopened = createOpenGuiHarness({ + dataDirectory, + homeDirectory: join(dataDirectory, "home"), + model: reopenedModel, + clock: new FakeClock("2026-07-10T10:00:00.000Z"), + ids: new SequenceIdGenerator(100), + compaction: { contextWindowTokens: 10_000 }, + resolveExecutionPolicy: async () => + projectScopedPolicy(await realpath(join(dataDirectory, "project"))), + }); + try { + const same = await reopened.openSession(sessionId); + for await (const _event of same.run({ text: "Continue after restart", actor: MEMBER })) { + // drain + } + const resume = reopenedModel.requests.find((request) => + request.context.some( + (item) => item.type === "user_message" && item.text.includes("HISTORICAL CONTEXT"), + ), + ); + expect(resume?.context[0]).toMatchObject({ + text: expect.stringContaining("Continue the fixture task."), + }); + } finally { + await reopened.close(); + } + }); + + test("ordinary foreign-project writes stay denied while compaction succeeds", async () => { + const { harness, session, model, dataDirectory } = await setupRestricted({ + mode: "outsideTaskWrite", + }); + try { + for await (const _event of session.run({ text: "Do work", actor: MEMBER, skills: [] })) { + // drain + } + const outputs = (await session.read()).entries + .filter((entry) => entry.kind === "tool_result") + .map((entry) => entry.payload.output); + expect(outputs[0]).toMatchObject({ denied: true, reason: "outside_grants" }); + await expect(readFile(join(dataDirectory, "outside.txt"), "utf8")).rejects.toThrow(); + + // Switch the shared fixture back to valid summaries for compaction. + model.mode = "valid"; + await drain(session.compact(MEMBER)); + expect( + (await session.read()).entries + .filter((entry) => entry.kind === "compaction") + .map((entry) => entry.payload.status), + ).toEqual(["started", "completed"]); + } finally { + await harness.close(); + } + }); + + test("actor-specific live instructions apply during compact and resume", async () => { + const { harness, session, model } = await setupRestricted({ + customInstructions: (actor) => `Instructions for ${actor?.id ?? "nobody"}`, + }); + try { + const alice: DurableActor = { type: "user", id: "alice", displayName: "Alice" }; + const bob: DurableActor = { type: "user", id: "bob", displayName: "Bob" }; + for await (const _event of session.run({ text: "Do work", actor: alice, skills: [] })) { + // drain + } + await drain(session.compact(bob)); + const compactionTurn = handoffTurns(model)[0]; + expect(compactionTurn?.systemPrompt).toContain("Instructions for bob"); + expect(compactionTurn?.systemPrompt).not.toContain("Instructions for alice"); + + await drain(session.run({ text: "Continue", actor: bob })); + const resumeTurn = model.requests.find((request) => + request.context.some( + (item) => item.type === "user_message" && item.text.includes("HISTORICAL CONTEXT"), + ), + ); + expect(resumeTurn?.systemPrompt).toContain("Instructions for bob"); + expect(resumeTurn?.systemPrompt).not.toContain("Instructions for alice"); + } finally { + await harness.close(); + } + }); +}); diff --git a/packages/harness/src/context/compaction.test.ts b/packages/harness/src/context/compaction.test.ts index a629582..b57059a 100644 --- a/packages/harness/src/context/compaction.test.ts +++ b/packages/harness/src/context/compaction.test.ts @@ -1,5 +1,23 @@ import { describe, expect, test } from "vite-plus/test"; -import { estimateContextTokens } from "./compaction.ts"; +import type { SessionEntry } from "../harness.ts"; +import { buildModelContext } from "./build-context.ts"; +import { + buildHandoffPrompt, + buildResumePrompt, + estimateContextTokens, + MAX_HANDOFF_BYTES, + parseCompactionHandoff, +} from "./compaction.ts"; + +const VALID_SUMMARY = { + goal: "Ship the fixture.", + currentState: "Scaffolding is done.", + constraints: "No network calls.", + decisions: "Use SQLite.", + blockers: "Waiting on review.", + relevantFiles: ["src/index.ts"], + nextSteps: ["Write tests.", "Run checks."], +}; describe("estimateContextTokens", () => { test("does not count persisted image bytes as text tokens", () => { @@ -22,3 +40,129 @@ describe("estimateContextTokens", () => { expect(tokens).toBeLessThan(1_000); }); }); + +describe("parseCompactionHandoff", () => { + test("renders required sections for a valid summary", () => { + const handoff = parseCompactionHandoff(JSON.stringify(VALID_SUMMARY)); + expect(handoff).toContain("## Goal"); + expect(handoff).toContain("Ship the fixture."); + expect(handoff).toContain("## Next steps"); + expect(handoff).toContain("1. Write tests."); + expect(handoff).toContain("- src/index.ts"); + }); + + test("accepts a single json fence", () => { + const handoff = parseCompactionHandoff(`\`\`\`json\n${JSON.stringify(VALID_SUMMARY)}\n\`\`\``); + expect(handoff).toContain("Ship the fixture."); + }); + + test("rejects empty responses", () => { + expect(() => parseCompactionHandoff(" \n")).toThrow("Compaction summary was empty"); + }); + + test("rejects oversized responses without truncating", () => { + const oversized = { ...VALID_SUMMARY, decisions: "x".repeat(MAX_HANDOFF_BYTES) }; + expect(() => parseCompactionHandoff(JSON.stringify(oversized))).toThrow( + "Compaction summary exceeds the size limit", + ); + }); + + test("also bounds the rendered summary rather than just its JSON response", () => { + const raw = JSON.stringify({ + ...VALID_SUMMARY, + goal: "x".repeat(MAX_HANDOFF_BYTES - 2_500), + relevantFiles: [], + nextSteps: Array.from({ length: 500 }, () => "x"), + }); + expect(Buffer.byteLength(raw, "utf8")).toBeLessThan(MAX_HANDOFF_BYTES); + expect(() => parseCompactionHandoff(raw)).toThrow("Compaction summary exceeds the size limit"); + }); + + test("rejects prose-only responses", () => { + expect(() => parseCompactionHandoff("# Handoff\n\nJust continue the work.\n")).toThrow( + "Compaction summary was invalid", + ); + }); + + test("rejects missing required fields", () => { + const { goal: _goal, ...withoutGoal } = VALID_SUMMARY; + expect(() => parseCompactionHandoff(JSON.stringify(withoutGoal))).toThrow( + "Compaction summary was invalid", + ); + }); + + test("rejects empty next steps", () => { + expect(() => + parseCompactionHandoff(JSON.stringify({ ...VALID_SUMMARY, nextSteps: [" "] })), + ).toThrow("Compaction summary was invalid"); + }); + + test("error messages expose no filesystem paths", () => { + for (const raw of ["", "x".repeat(MAX_HANDOFF_BYTES + 1), "plain prose"]) { + try { + parseCompactionHandoff(raw); + expect.unreachable(); + } catch (error) { + expect(error instanceof Error ? error.message : String(error)).not.toContain("HANDOFF"); + } + } + }); +}); + +describe("compaction context safety", () => { + const entry = (kind: SessionEntry["kind"], payload: Record): SessionEntry => ({ + id: "fixture", + sessionId: "session", + sequence: 1, + kind, + payload, + createdAt: "2026-10-06T00:00:00.000Z", + }); + + test("does not discard earlier history when a completed entry has no usable summary", () => { + const context = buildModelContext([ + entry("user_message", { text: "Preserve this original accepted intent" }), + entry("assistant_message", { text: "Preserve this work state" }), + entry("compaction", { status: "completed", handoff: " " }), + entry("user_message", { text: "Continue" }), + ]); + expect(JSON.stringify(context)).toContain("Preserve this original accepted intent"); + expect(JSON.stringify(context)).toContain("Preserve this work state"); + expect(JSON.stringify(context)).not.toContain("summary is unavailable"); + }); + + test("does not replay failed legacy compaction messages or tool effects as task context", () => { + const context = buildModelContext([ + entry("user_message", { text: "Task intent" }), + entry("assistant_message", { + text: "CONTEXT HANDOFF MODE legacy internals", + purpose: "compaction", + }), + entry("tool_call", { toolCallId: "old", name: "write", input: {}, purpose: "compaction" }), + entry("tool_result", { + toolCallId: "old", + output: "legacy denied write", + purpose: "compaction", + }), + ]); + expect(context).toHaveLength(1); + expect(JSON.stringify(context)).toContain("Task intent"); + expect(JSON.stringify(context)).not.toContain("legacy"); + }); +}); + +describe("compaction prompts", () => { + test("handoff prompt requests tool-free JSON and names no filesystem path", () => { + const prompt = buildHandoffPrompt(); + expect(prompt).toContain("CONTEXT HANDOFF MODE"); + expect(prompt).toContain("no tools"); + expect(prompt).toContain("JSON"); + expect(prompt).not.toContain("HANDOFF.md"); + }); + + test("resume prompt injects the summary as historical context", () => { + const prompt = buildResumePrompt("## Goal\nShip it."); + expect(prompt).toContain("HISTORICAL CONTEXT"); + expect(prompt).toContain("## Goal\nShip it."); + }); +}); diff --git a/packages/harness/src/context/compaction.ts b/packages/harness/src/context/compaction.ts index 26368e6..e222976 100644 --- a/packages/harness/src/context/compaction.ts +++ b/packages/harness/src/context/compaction.ts @@ -1,51 +1,169 @@ -import { join } from "node:path"; import type { SessionEntry } from "../harness.ts"; import { modelToolResultContent, type ModelContextItem } from "../models/transport.ts"; export const DEFAULT_COMPACTION_THRESHOLD_RATIO = 0.7; export const DEFAULT_CONTEXT_WINDOW_TOKENS = 128_000; -export interface CompactionPaths { - directory: string; - handoffPath: string; -} +/** Maximum accepted UTF-8 bytes for one compaction summary response. No silent truncation. */ +export const MAX_HANDOFF_BYTES = 64 * 1024; -export function compactionPaths( - tempDirectory: string, - sessionId: string, - runId: string, -): CompactionPaths { - const directory = join(tempDirectory, "opengui", "handoffs", sessionId, runId); - return { directory, handoffPath: join(directory, "HANDOFF.md") }; +export interface CompactionSummary { + goal: string; + currentState: string; + constraints: string; + decisions: string; + blockers: string; + relevantFiles: string[]; + nextSteps: string[]; } -export function buildHandoffPrompt(paths: CompactionPaths): string { +/** + * Compaction-only prompt. The model gets NO normal tools in this turn and must + * return a single structured JSON handoff. Nothing is written to any filesystem: + * the Harness validates the response and persists it on the completed + * compaction entry in Harness SQLite. + */ +export function buildHandoffPrompt(): string { return `CONTEXT HANDOFF MODE Your context limit is almost reached. STOP working on the task. Do not continue implementation or answer the user's request in this turn. -Create a durable handoff for another instance of you in this folder: -${paths.directory} +Return a single JSON object as your entire response. You have no tools in this turn; do not call any tools. Do not write any files. Do not print prose outside the JSON object (a single \`\`\`json fence around the object is acceptable). + +The JSON object must have exactly these fields: +{ + "goal": "the user's goal and constraints", + "currentState": "completed work and the current state", + "constraints": "constraints that must be respected going forward", + "decisions": "key decisions, notes, and learnings", + "blockers": "work in progress and blockers", + "relevantFiles": ["exact Project file paths relevant to the task"], + "nextSteps": ["clear ordered next steps"] +} + +Rules: +- goal, currentState, constraints, decisions, and blockers must be strings. Say explicitly when genuinely nothing applies instead of inventing detail. +- relevantFiles and nextSteps must be arrays of strings. relevantFiles may be empty when no files are relevant. +- goal, currentState, and nextSteps must be non-empty. +- Keep the whole JSON response under 64 KiB UTF-8.`; +} + +/** + * Resume context injected Harness-side from the durable summary. The handoff is + * historical evidence, never a live filesystem location, so resume requires no + * normal Tool access to any internal directory. + */ +export function buildResumePrompt(handoff: string): string { + return `HISTORICAL CONTEXT — compacted summary of earlier work in this Session. It is context, not a new user request. Treat file paths and decisions as historical evidence; verify against actual Project files before acting. -The required primary handoff file is: -${paths.handoffPath} +${handoff} -You retain all normal tools and may read, write, edit, and use the shell as needed to make the handoff accurate. You may create or download additional supporting files inside the handoff folder when useful. Do not put everything into one file if separate artifacts preserve important detail better. +Use the summary above to recover task state, then continue the user's work.`; +} + +function isNonEmpty(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} -HANDOFF.md must contain: -- the user's goal and constraints -- completed work and the current state -- work in progress and blockers -- key decisions, notes, and learnings -- exact paths of relevant Project files -- clear ordered next steps -- links or paths to every supporting artifact in this handoff folder +function isStringArray(value: unknown): value is string[] { + return ( + Array.isArray(value) && value.every((item) => typeof item === "string") && value.length <= 500 + ); +} -Write the files using your tools. Verify that HANDOFF.md exists and is useful before stopping. Do not merely print the handoff in chat. When the handoff is complete, stop.`; +function extractJsonDocument(raw: string): unknown { + const trimmed = raw.trim(); + const fence = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/); + return JSON.parse(fence?.[1] ?? trimmed); } -export function buildResumePrompt(directory: string): string { - return `A previous context was compacted. Read and inspect the handoff folder at ${directory}, starting with HANDOFF.md. Use the files there to recover the task state, then continue the user's work. The handoff folder is context, not a new user request.`; +function toSummary(value: Record): CompactionSummary { + const { goal, currentState, constraints, decisions, blockers, relevantFiles, nextSteps } = value; + if (!isNonEmpty(goal)) throw new Error("Compaction summary was invalid: goal is required"); + if (!isNonEmpty(currentState)) + throw new Error("Compaction summary was invalid: currentState is required"); + if ( + typeof constraints !== "string" || + typeof decisions !== "string" || + typeof blockers !== "string" + ) { + throw new Error( + "Compaction summary was invalid: constraints, decisions, and blockers are required", + ); + } + if (!isStringArray(relevantFiles) || !isStringArray(nextSteps)) { + throw new Error("Compaction summary was invalid: relevantFiles and nextSteps are required"); + } + if (nextSteps.every((step) => !step.trim())) { + throw new Error("Compaction summary was invalid: nextSteps must not be empty"); + } + return { + goal: goal.trim(), + currentState: currentState.trim(), + constraints: constraints.trim(), + decisions: decisions.trim(), + blockers: blockers.trim(), + relevantFiles: relevantFiles.map((file) => file.trim()).filter((file) => file.length > 0), + nextSteps: nextSteps.map((step) => step.trim()).filter((step) => step.length > 0), + }; +} + +export function renderHandoffMarkdown(summary: CompactionSummary): string { + const files = + summary.relevantFiles.length > 0 + ? summary.relevantFiles.map((file) => `- ${file}`).join("\n") + : "None recorded."; + return [ + "# Context handoff (compacted summary — historical context, not a new request)", + "", + "## Goal", + summary.goal, + "", + "## Current state", + summary.currentState, + "", + "## Constraints", + summary.constraints || "None recorded.", + "", + "## Decisions and learnings", + summary.decisions || "None recorded.", + "", + "## Blockers / work in progress", + summary.blockers || "None recorded.", + "", + "## Relevant files", + files, + "", + "## Next steps", + summary.nextSteps.map((step, index) => `${index + 1}. ${step}`).join("\n"), + "", + ].join("\n"); +} + +/** + * Validate one compaction-only model response and render the durable handoff + * text. Rejects empty, oversized, prose-only, and schema-invalid responses with + * path-free errors. Never truncates. + */ +export function parseCompactionHandoff(raw: string): string { + if (!raw.trim()) throw new Error("Compaction summary was empty"); + if (Buffer.byteLength(raw, "utf8") > MAX_HANDOFF_BYTES) { + throw new Error("Compaction summary exceeds the size limit"); + } + let document: unknown; + try { + document = extractJsonDocument(raw); + } catch { + throw new Error("Compaction summary was invalid: expected a single JSON object"); + } + if (!document || typeof document !== "object" || Array.isArray(document)) { + throw new Error("Compaction summary was invalid: expected a single JSON object"); + } + const handoff = renderHandoffMarkdown(toSummary(document as Record)); + if (Buffer.byteLength(handoff, "utf8") > MAX_HANDOFF_BYTES) { + throw new Error("Compaction summary exceeds the size limit"); + } + return handoff; } function textLength(value: unknown): number { diff --git a/packages/harness/src/harness.test.ts b/packages/harness/src/harness.test.ts index 98e635f..bd27a04 100644 --- a/packages/harness/src/harness.test.ts +++ b/packages/harness/src/harness.test.ts @@ -136,29 +136,25 @@ description: Review code changes and pull requests. Use when reviewing diffs or await harness.close(); }); - test("compacts through a hidden warm-context handoff and resumes from its temp folder", async () => { + test("compacts through an injected handoff summary and resumes from durable history", async () => { const dataDirectory = await temporaryDirectory(); - const handoffRoot = join(dataDirectory, "tmp"); + // Points at a directory that does not exist: handoff storage must not + // touch the filesystem. + const missingRoot = join(dataDirectory, "missing-tmp"); const projectDirectory = join(dataDirectory, "project"); await mkdir(projectDirectory); - const handoffDirectory = join(handoffRoot, "opengui", "handoffs", "session-1", "run-10"); - const handoffPath = join(handoffDirectory, "HANDOFF.md"); + const summary = { + goal: "Continue the task.", + currentState: "Initial work is done.", + constraints: "Stay inside the fixture project.", + decisions: "Single summary response.", + blockers: "None.", + relevantFiles: ["README.md"], + nextSteps: ["Continue."], + }; const model = new FakeModel([ { text: `Long work state ${"x".repeat(1_000)}` }, - { - toolCalls: [ - { - id: "write-handoff", - name: "write", - input: { - path: handoffPath, - content: - "# Handoff\n\n## Goal\nContinue the task.\n\n## Current state\nInitial work is done.\n\n## Relevant files\n- README.md\n\n## Next steps\n1. Continue.\n", - }, - }, - ], - }, - { text: "Handoff ready." }, + { text: JSON.stringify(summary) }, { text: "Continued from the handoff." }, ]); const harness = createOpenGuiHarness({ @@ -169,7 +165,7 @@ description: Review code changes and pull requests. Use when reviewing diffs or compaction: { contextWindowTokens: 300, thresholdRatio: 0.7, - tempDirectory: handoffRoot, + tempDirectory: missingRoot, }, }); const session = await harness.createSession({ @@ -184,32 +180,38 @@ description: Review code changes and pull requests. Use when reviewing diffs or const events = []; for await (const event of session.run({ text: "Continue" })) events.push(event); - expect(model.requests[1]?.systemPrompt).toBe(model.requests[0]?.systemPrompt); - expect(model.requests[1]?.tools).toEqual(model.requests[0]?.tools); + // The compaction turn offers no normal tools. + expect(model.requests[1]?.tools).toEqual([]); expect(model.requests[1]?.context.at(-1)).toMatchObject({ type: "user_message", - text: expect.stringContaining(handoffPath), + text: expect.stringContaining("CONTEXT HANDOFF MODE"), }); - expect(model.requests[1]?.context.at(-1)).toMatchObject({ - text: expect.stringContaining("STOP working on the task"), + expect(model.requests[1]?.context.at(-1)).not.toMatchObject({ + text: expect.stringContaining("HANDOFF.md"), }); - expect(model.requests[3]?.context).toEqual([ + // Resume injects the durable summary as historical context without any + // filesystem path or normal Tool read. + expect(model.requests[2]?.context).toEqual([ expect.objectContaining({ type: "user_message", - text: expect.stringContaining(`Read and inspect the handoff folder at ${handoffDirectory}`), + text: expect.stringContaining("HISTORICAL CONTEXT"), }), ]); - expect(await readFile(handoffPath, "utf8")).toContain("## Next steps"); + expect(model.requests[2]?.context[0]).toMatchObject({ + text: expect.stringContaining("Continue the task."), + }); const snapshot = await session.read(); const compactions = snapshot.entries.filter((entry) => entry.kind === "compaction"); expect(compactions.map((entry) => entry.payload.status)).toEqual(["started", "completed"]); expect(compactions.at(-1)?.payload).toMatchObject({ - handoffDirectory, - handoffPath, + handoff: expect.stringContaining("## Next steps"), thresholdRatio: 0.7, }); + expect(compactions.at(-1)?.payload).not.toHaveProperty("handoffDirectory"); + expect(compactions.at(-1)?.payload).not.toHaveProperty("handoffPath"); expect(JSON.stringify(snapshot.entries)).not.toContain("STOP working on the task"); + expect(JSON.stringify(snapshot.entries)).not.toContain("CONTEXT HANDOFF MODE"); expect(events).toContainEqual( expect.objectContaining({ type: "entry_appended", @@ -232,20 +234,18 @@ description: Review code changes and pull requests. Use when reviewing diffs or skillPath, "---\nname: stable\ndescription: Original stable instructions.\n---\n# Original\n", ); - const handoffDirectory = join(handoffRoot, "opengui", "handoffs", "session-1", "run-10"); - const handoffPath = join(handoffDirectory, "HANDOFF.md"); + const summary = { + goal: "Resume later.", + currentState: "Initial work is done.", + constraints: "None.", + decisions: "None.", + blockers: "None.", + relevantFiles: [], + nextSteps: ["Resume later."], + }; const model = new FakeModel([ { text: "Initial work." }, - { - toolCalls: [ - { - id: "manual-handoff", - name: "write", - input: { path: handoffPath, content: "# Handoff\n\n## Next steps\n1. Resume later.\n" }, - }, - ], - }, - { text: "Handoff ready." }, + { text: JSON.stringify(summary) }, { text: "Resumed on the next user turn." }, ]); const harness = createOpenGuiHarness({ @@ -281,24 +281,28 @@ description: Review code changes and pull requests. Use when reviewing diffs or } expect(model.requests[0]?.systemPrompt).toContain("Preferences for alice"); expect(model.requests[1]?.systemPrompt).toContain("Preferences for bob"); - expect(model.requests[2]?.systemPrompt).toContain("Preferences for bob"); expect(model.requests[1]?.systemPrompt).not.toContain("Preferences for alice"); - expect(model.requests).toHaveLength(3); + // Single tool-free summary response: no second compaction turn. + expect(model.requests).toHaveLength(2); + expect(model.requests[1]?.tools).toEqual([]); expect(model.requests[1]?.systemPrompt).toContain("Original stable instructions."); expect(model.requests[1]?.systemPrompt).not.toContain("Mutated instructions."); - expect(model.requests[2]?.systemPrompt).toContain("Original stable instructions."); expect((await session.read()).status).toBe("idle"); for await (const _event of session.run({ text: "Now continue" })) { // drain } - expect(model.requests[3]?.context).toMatchObject([ + expect(model.requests[2]?.systemPrompt).toContain("Original stable instructions."); + expect(model.requests[2]?.context).toMatchObject([ { type: "user_message", - text: expect.stringContaining(`Read and inspect the handoff folder at ${handoffDirectory}`), + text: expect.stringContaining("HISTORICAL CONTEXT"), }, { type: "user_message", text: "Now continue" }, ]); + expect(model.requests[2]?.context[0]).toMatchObject({ + text: expect.stringContaining("Resume later."), + }); await harness.close(); }); diff --git a/packages/harness/src/harness.ts b/packages/harness/src/harness.ts index e5937cb..c577b31 100644 --- a/packages/harness/src/harness.ts +++ b/packages/harness/src/harness.ts @@ -148,12 +148,12 @@ export interface OpenGuiHarnessOptions { /** Additional Host-owned tools resolved and authorized for each model turn. */ agentTools?: AgentToolSource; shell?: { executable?: string }; - /** In-band context handoff. The current model writes a checkpoint before context is reset. */ + /** In-band context handoff. The model returns a structured summary the Harness persists. */ compaction?: { enabled?: boolean; contextWindowTokens?: number; thresholdRatio?: number; - /** Root for opengui/handoffs. Defaults to the current user's OS temp directory. */ + /** Ignored. Retained so existing embeddings still construct; no handoff files are written. */ tempDirectory?: string; }; /** Home directory used for `~/.agents/skills` discovery. Defaults to os.homedir(). */ diff --git a/packages/harness/src/open-gui-harness.ts b/packages/harness/src/open-gui-harness.ts index 023bbaa..46b53a9 100644 --- a/packages/harness/src/open-gui-harness.ts +++ b/packages/harness/src/open-gui-harness.ts @@ -5,11 +5,12 @@ import { dirname, join, resolve } from "node:path"; import { buildModelContext } from "./context/build-context.ts"; import { buildHandoffPrompt, - compactionPaths, DEFAULT_COMPACTION_THRESHOLD_RATIO, DEFAULT_CONTEXT_WINDOW_TOKENS, estimateContextTokens, latestCompletedCompaction, + MAX_HANDOFF_BYTES, + parseCompactionHandoff, } from "./context/compaction.ts"; import { buildSystemPrompt } from "./context/system-prompt.ts"; import { type ExecutionPolicy, unrestrictedExecutionPolicy } from "./execution-policy.ts"; @@ -554,7 +555,6 @@ class OpenGuiHarnessImpl implements OpenGuiHarness { sessionId: string; runId: string; snapshot: SessionSnapshot; - tools: ModelToolName[]; systemPrompt: string; signal: AbortSignal; tokensBefore: number; @@ -562,8 +562,9 @@ class OpenGuiHarnessImpl implements OpenGuiHarness { revalidate: () => Promise<{ policy: ExecutionPolicy; canonicalProjectRoot: string }>; actor?: PromptInput["actor"]; }): AsyncIterable { - const paths = compactionPaths(this.#compaction.tempDirectory, input.sessionId, input.runId); - await mkdir(paths.directory, { recursive: true }); + // The handoff lives only in this response and the completed compaction + // entry. No filesystem directory is created and no normal Tools are + // offered, so restricted policies cannot deny compaction storage. yield { type: "entry_appended", entry: await this.#store.appendEntry( @@ -572,8 +573,6 @@ class OpenGuiHarnessImpl implements OpenGuiHarness { { runId: input.runId, status: "started", - handoffDirectory: paths.directory, - handoffPath: paths.handoffPath, tokensBefore: input.tokensBefore, thresholdRatio: this.#compaction.thresholdRatio, reason: input.reason, @@ -586,163 +585,79 @@ class OpenGuiHarnessImpl implements OpenGuiHarness { ...buildModelContext(input.snapshot.entries), { type: "user_message" as const, - text: buildHandoffPrompt(paths), + text: buildHandoffPrompt(), model: input.snapshot.model!, reasoning: input.snapshot.reasoning!, }, ]; - while (true) { - let assistantText = ""; - let reasoningText = ""; - const toolCalls: Array<{ id: string; name: string; input: unknown }> = []; - const modelRequest: ModelRequest = { - identity: { - hostId: this.#hostId, - sessionId: input.sessionId, - runId: input.runId, - principalId: input.actor ? `${input.actor.type}:${input.actor.id}` : "local:legacy", - }, - projectDirectory: input.snapshot.projectDirectory, - actor: input.actor, - context, - tools: input.tools, + // Single tool-free model response. Internal summary text is never yielded + // as live task deltas and never persisted as task entries; unexpected + // tool calls are refused without execution, so there is no tool-call loop. + let summaryText = ""; + let summaryBytes = 0; + let completed = false; + let providerResponse: ProviderResponseMetadata | undefined; + const modelRequest: ModelRequest = { + identity: { + hostId: this.#hostId, + sessionId: input.sessionId, + runId: input.runId, + principalId: input.actor ? `${input.actor.type}:${input.actor.id}` : "local:legacy", + }, + projectDirectory: input.snapshot.projectDirectory, + actor: input.actor, + context, + tools: [], + toolDefinitions: [], + systemPrompt: input.systemPrompt, + cache: createModelCachePolicy({ systemPrompt: input.systemPrompt, - cache: createModelCachePolicy({ - systemPrompt: input.systemPrompt, - tools: input.tools, - toolSchemas: toolDefinitionsFor(input.tools), - permissionScope: { tools: input.tools, project: input.snapshot.projectDirectory }, - skillRevisions: - lockedSkillPinsFromEntries(input.snapshot.entries)?.map((pin) => pin.revision) ?? [], - compactionId: latestCompletedCompaction(input.snapshot.entries)?.entry.id, - }), - delivery: { ...DEFAULT_MODEL_DELIVERY }, - }; - let providerResponse: ProviderResponseMetadata | undefined; - for await (const event of this.#model.stream(modelRequest, input.signal)) { - await input.revalidate(); - input.signal.throwIfAborted(); - if (event.type === "text_delta") { - assistantText += event.delta; - yield { type: "assistant_delta", runId: input.runId, delta: event.delta }; - } else if (event.type === "reasoning_delta") { - reasoningText += event.delta; - yield { type: "reasoning_delta", runId: input.runId, delta: event.delta }; - } else if (event.type === "tool_call") { - toolCalls.push({ id: event.id, name: event.name, input: event.input }); - } else if (event.type === "completed") { - providerResponse = event.response; + tools: [], + toolSchemas: [], + permissionScope: { tools: [], project: input.snapshot.projectDirectory }, + skillRevisions: + lockedSkillPinsFromEntries(input.snapshot.entries)?.map((pin) => pin.revision) ?? [], + compactionId: latestCompletedCompaction(input.snapshot.entries)?.entry.id, + }), + delivery: { ...DEFAULT_MODEL_DELIVERY }, + }; + for await (const event of this.#model.stream(modelRequest, input.signal)) { + await input.revalidate(); + input.signal.throwIfAborted(); + if (event.type === "text_delta") { + summaryBytes += Buffer.byteLength(event.delta, "utf8"); + if (summaryBytes > MAX_HANDOFF_BYTES + 1024) { + throw new Error("Compaction summary exceeds the size limit"); } - } - - if (providerResponse) { - yield { - type: "entry_appended", - entry: await this.#store.appendEntry( - input.sessionId, - "provider_response", - { runId: input.runId, response: providerResponse, purpose: "compaction" }, - this.#clock.now().toISOString(), - ), - }; - } - - if (reasoningText) { - yield { - type: "entry_appended", - entry: await this.#store.appendEntry( - input.sessionId, - "assistant_reasoning", - { runId: input.runId, text: reasoningText, purpose: "compaction" }, - this.#clock.now().toISOString(), - ), - }; - } - if (assistantText) { - context.push({ type: "assistant_message", text: assistantText }); - yield { - type: "entry_appended", - entry: await this.#store.appendEntry( - input.sessionId, - "assistant_message", - { runId: input.runId, text: assistantText, purpose: "compaction" }, - this.#clock.now().toISOString(), - ), - }; - } - if (toolCalls.length === 0) break; - - for (const toolCall of toolCalls) { - context.push({ - type: "tool_call", - toolCallId: toolCall.id, - name: toolCall.name, - input: toolCall.input, - }); - yield { - type: "entry_appended", - entry: await this.#store.appendEntry( - input.sessionId, - "tool_call", - { - runId: input.runId, - toolCallId: toolCall.id, - name: toolCall.name, - input: toolCall.input, - purpose: "compaction", - }, - this.#clock.now().toISOString(), - ), - }; - } - for (const toolCall of toolCalls) { - const { policy } = await input.revalidate(); - input.signal.throwIfAborted(); - const output = await executeTool( - { - projectDirectory: input.snapshot.projectDirectory, - dataDirectory: this.#dataDirectory, - sessionId: input.sessionId, - toolCallId: toolCall.id, - shell: this.#shell, - signal: input.signal, - executionPolicy: policy, - }, - toolCall.name, - toolCall.input, - ); - context.push({ - type: "tool_result", - toolCallId: toolCall.id, - name: toolCall.name, - output, - }); - yield { - type: "entry_appended", - entry: await this.#store.appendEntry( - input.sessionId, - "tool_result", - { - runId: input.runId, - toolCallId: toolCall.id, - name: toolCall.name, - output, - purpose: "compaction", - }, - this.#clock.now().toISOString(), - ), - }; + summaryText += event.delta; + } else if (event.type === "tool_call" || event.type === "tool_call_delta") { + throw new Error("Compaction response must not call tools"); + } else if (event.type === "completed") { + completed = true; + providerResponse = event.response; } } - let handoff: string; - try { - handoff = await readFile(paths.handoffPath, "utf8"); - } catch { - throw new Error(`Compaction did not create ${paths.handoffPath}`); + // Re-resolve authorization and cancellation immediately before publishing. + await input.revalidate(); + input.signal.throwIfAborted(); + if (providerResponse) { + yield { + type: "entry_appended", + entry: await this.#store.appendEntry( + input.sessionId, + "provider_response", + { runId: input.runId, response: providerResponse, purpose: "compaction" }, + this.#clock.now().toISOString(), + ), + }; + } + if (!completed || (providerResponse && providerResponse.stopReason !== "stop")) { + throw new Error("Compaction summary response was incomplete"); } - if (!handoff.trim()) throw new Error(`Compaction created an empty ${paths.handoffPath}`); + const handoff = parseCompactionHandoff(summaryText); + input.signal.throwIfAborted(); yield { type: "entry_appended", @@ -752,8 +667,6 @@ class OpenGuiHarnessImpl implements OpenGuiHarness { { runId: input.runId, status: "completed", - handoffDirectory: paths.directory, - handoffPath: paths.handoffPath, handoff, tokensBefore: input.tokensBefore, thresholdRatio: this.#compaction.thresholdRatio, @@ -813,13 +726,12 @@ class OpenGuiHarnessImpl implements OpenGuiHarness { ), ); const skills = pinned.skills; - const modelAccess = await revalidate(snapshot.projectDirectory); - const tools = await this.#toolsForModel(modelAccess.policy, modelAccess.canonicalProjectRoot); - const shellAvailable = tools.includes("shell"); + await revalidate(snapshot.projectDirectory); + // Compaction offers no normal Tools, so no shell is advertised; live + // actor-specific instructions are preserved for the summary turn. const systemPrompt = buildSystemPrompt({ projectDirectory: snapshot.projectDirectory, - ...(shellAvailable ? { shell: this.#shell } : {}), - tools, + tools: [], skills, customInstructions: await this.#resolveCustomInstructions?.({ actor, @@ -847,7 +759,6 @@ class OpenGuiHarnessImpl implements OpenGuiHarness { sessionId, runId, snapshot, - tools, systemPrompt, signal: abortController.signal, tokensBefore: contextTokens, @@ -1022,15 +933,16 @@ class OpenGuiHarnessImpl implements OpenGuiHarness { const toolDefinitions = [...builtInDefinitions, ...(agentToolSet?.definitions ?? [])]; const modelToolNames = toolDefinitions.map((definition) => definition.name); const shellAvailable = modelToolNames.includes("shell"); + const customInstructions = await this.#resolveCustomInstructions?.({ + actor: nextPrompt.actor, + projectDirectory: current.projectDirectory, + }); const systemPrompt = buildSystemPrompt({ projectDirectory: current.projectDirectory, ...(shellAvailable ? { shell: this.#shell } : {}), tools: modelToolNames, skills, - customInstructions: await this.#resolveCustomInstructions?.({ - actor: nextPrompt.actor, - projectDirectory: current.projectDirectory, - }), + customInstructions, now: this.#clock.now(), }); const modelContext = buildModelContext(current.entries); @@ -1041,12 +953,20 @@ class OpenGuiHarnessImpl implements OpenGuiHarness { this.#compaction.contextWindowTokens * this.#compaction.thresholdRatio && latestCompletedCompaction(current.entries)?.entry.id !== current.entries.at(-1)?.id; if (shouldCompact) { + // Compaction offers no normal Tools; live actor instructions and + // skills are preserved, but no shell is advertised. + const compactionSystemPrompt = buildSystemPrompt({ + projectDirectory: current.projectDirectory, + tools: [], + skills, + customInstructions, + now: this.#clock.now(), + }); for await (const event of this.#performCompaction({ sessionId, runId, snapshot: current, - tools, - systemPrompt, + systemPrompt: compactionSystemPrompt, signal: abortController.signal, tokensBefore: contextTokens, reason: "threshold",