From 7b66daa67c1de5df5277222d57b4cc23cb6036ab Mon Sep 17 00:00:00 2001 From: RaghwendraSingh Date: Tue, 3 Nov 2026 15:21:40 +0530 Subject: [PATCH 1/3] fix(session): compact once per overflow and list shell-written files in the ledger Double summary: after a compaction that keeps a tail, `filterCompacted` renders `[compaction, summary, ...tail, continue]`. The prompt loop took the array-last finished assistant as `lastFinished`, which is the retained pre-compaction turn still carrying its overflowing usage, so the overflow check fired again before any new request and wrote a second summary. The loop now selects `lastFinished` by id via `MessageV2.latest(msgs).finished`, which lands on the summary that the overflow check skips. One overflow now produces one summary. Ledger: the compaction ledger only listed files written by write/edit tools, so files created or changed by shell commands (redirects, scripts, `sed -i`) were missing and the continuing agent re-read or re-created them. `buildLedger` now reads the step `patch` parts (per-step snapshot diffs) and lists those files "via shell", keeps write/edit attribution for files a tool wrote in the same step, and drops listed files that no longer exist. The file-existence check is injectable for unit tests. Co-Authored-By: Claude Opus 5.5 --- packages/opencode/src/session/compaction.ts | 62 +++++++++++++-- packages/opencode/src/session/prompt.ts | 6 ++ .../session/compaction-double-summary.test.ts | 53 +++++++++++++ .../test/session/compaction-ledger.test.ts | 76 +++++++++++++++++++ 4 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 packages/opencode/test/session/compaction-double-summary.test.ts diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index d2f9d05cf7..a7b33d99c4 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -32,6 +32,7 @@ import { Context, Effect, Layer } from "effect" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { serviceUse } from "@opencode-ai/core/effect/service-use" import path from "node:path" +import { existsSync } from "node:fs" // altimate_change — ledger: drop snapshot-listed files that no longer exist import { TraceContext } from "@/altimate/observability/trace-context" // altimate_change — client trace per turn // altimate_change end // altimate_change start — replay cleanup uses server-side lesson delivery provenance. @@ -827,7 +828,10 @@ export namespace SessionCompaction { exit?: number | null errored: boolean } - export type Ledger = { writes: LedgerWrite[]; calls: LedgerCall[]; sawBash: boolean } + // altimate_change start — `sawSnapshot`: the session recorded per-step snapshot + // diffs, so shell-made changes to tracked and non-ignored files are in `writes`. + export type Ledger = { writes: LedgerWrite[]; calls: LedgerCall[]; sawBash: boolean; sawSnapshot?: boolean } + // altimate_change end function callDetail(input: Record | null | undefined): string { if (!input || typeof input !== "object") return "" @@ -865,26 +869,61 @@ export namespace SessionCompaction { return (root ? path.resolve(root, normalized) : normalized).replaceAll("\\", "/") } - export function buildLedger(messages: MessageV2.WithParts[], root?: string): Ledger { + export function buildLedger( + messages: MessageV2.WithParts[], + root?: string, + // altimate_change — injectable so unit tests need no real files + exists: (file: string) => boolean = existsSync, + ): Ledger { const writes = new Map() const calls: LedgerCall[] = [] let sawBash = false + // altimate_change start — snapshot diffs record every file a step changed, + // however it was changed (bash redirects, scripts, `sed -i`), which edit-tool + // events alone miss. + let sawSnapshot = false + // altimate_change end for (const msg of messages) { + // altimate_change start + const toolWritten = new Set() + let lastEnd: number | undefined + // altimate_change end for (const part of msg.parts) { + // altimate_change start — a patch part lists the files its step changed. + // Files already recorded by a write/edit tool in this step keep that + // attribution; a listed file that no longer exists was deleted. + if (part.type === "patch") { + sawSnapshot = true + const at = lastEnd ?? msg.info.time?.created ?? 0 + for (const file of part.files) { + const key = ledgerPathKey(file, root) + if (toolWritten.has(key)) continue + if (!exists(key)) { + writes.delete(key) + continue + } + writes.set(key, { path: file, mtime: at, tool: "shell" }) + } + continue + } + // altimate_change end if (part.type !== "tool") continue const state = part.state if (state.status !== "completed" && state.status !== "error") continue const errored = state.status === "error" const metadata: Record = state.metadata ?? {} const exit = typeof metadata.exit === "number" || metadata.exit === null ? metadata.exit : undefined + lastEnd = state.time.end // altimate_change — patch-part write time calls.push({ tool: part.tool, detail: callDetail(state.input), exit, errored }) if (part.tool === "bash") sawBash = true if (errored) continue if (LEDGER_WRITE_TOOLS.has(part.tool)) { const filePath = typeof state.input?.filePath === "string" ? state.input.filePath : undefined // mtime = tool-event completion time, NOT an fs.stat — corroborated facts only. - if (filePath) + if (filePath) { writes.set(ledgerPathKey(filePath, root), { path: filePath, mtime: state.time.end, tool: part.tool }) + toolWritten.add(ledgerPathKey(filePath, root)) // altimate_change + } } if (part.tool === "apply_patch") { const files = Array.isArray(metadata.files) ? metadata.files : [] @@ -901,12 +940,14 @@ export namespace SessionCompaction { // it sends the continuing agent back to the path that was removed. if (typeof f?.movePath === "string" && source) writes.delete(ledgerPathKey(source, root)) const target = typeof f?.movePath === "string" ? f.movePath : f?.filePath - if (typeof target === "string") + if (typeof target === "string") { writes.set(ledgerPathKey(target, root), { path: target, mtime: state.time.end, tool: "apply_patch", }) + toolWritten.add(ledgerPathKey(target, root)) // altimate_change + } } } } @@ -915,6 +956,7 @@ export namespace SessionCompaction { writes: [...writes.values()].sort((a, b) => b.mtime - a.mtime || a.path.localeCompare(b.path)), calls, sawBash, + sawSnapshot, // altimate_change } } @@ -929,18 +971,26 @@ export namespace SessionCompaction { if (!ledger.writes.length && !ledger.calls.length) return "" const lines: string[] = ["[Session state ledger — harness-recorded facts, generated automatically at compaction]"] if (ledger.writes.length) { - lines.push("Files you wrote this session (verified write/edit tool events):") + // altimate_change — includes files changed by shell commands, from snapshot diffs + lines.push("Files you wrote this session (verified by write/edit tool events or workspace snapshot diffs):") for (const w of ledger.writes) { lines.push( `- ${redactLedgerDetail(w.path)} — last written by you at ${new Date(w.mtime).toISOString()} via ${w.tool}`, ) } } - if (ledger.sawBash) { + // altimate_change start — with snapshot diffs, shell changes are listed above + // except where the snapshot does not look: git-ignored files and files over 2 MB. + if (ledger.sawBash && ledger.sawSnapshot) { + lines.push( + "Files changed by shell commands are listed above (via shell); changes to git-ignored files or files over 2 MB are not tracked.", + ) + } else if (ledger.sawBash) { lines.push( "Shell commands also ran this session; any file changes they made are possible but unverified (shell writes produce no edit event).", ) } + // altimate_change end lines.push( "Advisory: these files were last written by you at the times shown — prefer this ledger over re-reading them; re-read a file only if a tool errored, you suspect external changes (e.g. IDE edits), or you are about to edit it.", ) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index bd832d27ab..0253b5d584 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1160,6 +1160,12 @@ export namespace SessionPrompt { tasks.push(...task) } } + // altimate_change start — filterCompacted renders the retained tail after + // the summary, so the array-last finished assistant is a pre-compaction turn + // still carrying its overflowing usage. Select it by id so the overflow check + // sees the summary and does not compact again before the next request. + lastFinished = MessageV2.latest(msgs).finished + // altimate_change end if (!lastUser) throw new Error("No user message found in stream. This should never happen.") // altimate_change start - only compaction-created continuations belong to the same request diff --git a/packages/opencode/test/session/compaction-double-summary.test.ts b/packages/opencode/test/session/compaction-double-summary.test.ts new file mode 100644 index 0000000000..17d2102e4d --- /dev/null +++ b/packages/opencode/test/session/compaction-double-summary.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import path from "node:path" +import { MessageV2 } from "../../src/session/message-v2" +import { MessageID } from "../../src/session/schema" + +// After a compaction that keeps a tail, filterCompacted renders +// [compaction, summary, ...tail, continue]. The prompt loop's overflow check +// must read the summary, not the last tail assistant, whose recorded usage is +// the pre-compaction context; otherwise it compacts again before any request. + +const TAIL_USER = MessageID.make("msg_001") +const OVERFLOW_ASSISTANT = MessageID.make("msg_002") +const COMPACTION_USER = MessageID.make("msg_003") +const SUMMARY_ASSISTANT = MessageID.make("msg_004") +const CONTINUE_USER = MessageID.make("msg_005") + +const tokens = (input: number) => ({ input, output: 100, reasoning: 0, cache: { read: 0, write: 0 }, total: input + 100 }) +const user = (id: string, parts: any[]) => ({ info: { id, role: "user", sessionID: "s" }, parts }) +const assistant = (id: string, parentID: string, extra: Record) => ({ + info: { id, role: "assistant", sessionID: "s", parentID, ...extra }, + parts: [], +}) + +const newestFirst = [ + user(CONTINUE_USER, [{ type: "text", text: "Continue", synthetic: true }]), + assistant(SUMMARY_ASSISTANT, COMPACTION_USER, { summary: true, finish: "stop", tokens: tokens(150_000) }), + user(COMPACTION_USER, [{ type: "compaction", auto: true, tail_start_id: TAIL_USER }]), + assistant(OVERFLOW_ASSISTANT, TAIL_USER, { finish: "tool-calls", tokens: tokens(180_000) }), + user(TAIL_USER, [{ type: "text", text: "continue the task" }]), +] as unknown as MessageV2.WithParts[] + +describe("post-compaction overflow check", () => { + test("the array-last finished assistant is the retained pre-compaction turn", () => { + const msgs = MessageV2.filterCompacted(newestFirst) + const arrayLast = msgs.findLast((m) => m.info.role === "assistant" && (m.info as MessageV2.Assistant).finish) + expect(arrayLast?.info.id).toBe(OVERFLOW_ASSISTANT) + }) + + test("selection by id lands on the summary, which the overflow check skips", () => { + const finished = MessageV2.latest(MessageV2.filterCompacted(newestFirst)).finished + expect(finished?.id).toBe(SUMMARY_ASSISTANT) + expect(finished?.summary).toBe(true) + }) + + test("the prompt loop selects lastFinished by id before the overflow check", () => { + const source = readFileSync(path.join(import.meta.dir, "../../src/session/prompt.ts"), "utf8") + const select = source.indexOf("lastFinished = MessageV2.latest(msgs).finished") + const check = source.indexOf("estimateUncountedTail(msgs, lastFinished?.id)") + expect(select).toBeGreaterThan(0) + expect(check).toBeGreaterThan(select) + }) +}) diff --git a/packages/opencode/test/session/compaction-ledger.test.ts b/packages/opencode/test/session/compaction-ledger.test.ts index bb724360f9..0cc7af67d1 100644 --- a/packages/opencode/test/session/compaction-ledger.test.ts +++ b/packages/opencode/test/session/compaction-ledger.test.ts @@ -730,3 +730,79 @@ describe("leak guard", () => { expect(a.replace("dbt build --select orders", "CMD")).toBe(b.replace("qqq frobnicate --select orders", "CMD")) }) }) + +// ─── Snapshot diffs: files changed by shell commands ───────────────────────── + +function patchPart(files: string[]): any { + partCounter++ + return { id: `part-p-${partCounter}`, sessionID: "session-1", messageID: "m", type: "patch", hash: "h", files } +} + +describe("SessionCompaction.buildLedger — snapshot diffs", () => { + const onDisk = (paths: string[]) => (file: string) => paths.includes(file) + + test("a file written by a bash command is listed from the step's patch part", () => { + const messages = [ + assistantMsg([ + toolPart({ tool: "bash", input: { command: "echo hi >> notes.md" }, metadata: { exit: 0 }, end: 7000 }), + patchPart(["/repo/notes.md"]), + ]), + ] + const ledger = SessionCompaction.buildLedger(messages, undefined, onDisk(["/repo/notes.md"])) + expect(ledger.writes).toEqual([{ path: "/repo/notes.md", mtime: 7000, tool: "shell" }]) + expect(ledger.sawSnapshot).toBe(true) + const text = SessionCompaction.renderLedger(ledger) + expect(text).toContain("/repo/notes.md") + expect(text).toContain("via shell") + expect(text).not.toContain("possible but unverified") + }) + + test("a file written by a write tool in the same step keeps the tool attribution", () => { + const messages = [ + assistantMsg([ + toolPart({ tool: "write", input: { filePath: "/repo/a.sql" }, end: 5000 }), + toolPart({ tool: "bash", input: { command: "python gen.py" }, metadata: { exit: 0 }, end: 6000 }), + patchPart(["/repo/a.sql", "/repo/out/report.md"]), + ]), + ] + const ledger = SessionCompaction.buildLedger(messages, undefined, onDisk(["/repo/a.sql", "/repo/out/report.md"])) + expect(ledger.writes).toEqual([ + { path: "/repo/out/report.md", mtime: 6000, tool: "shell" }, + { path: "/repo/a.sql", mtime: 5000, tool: "write" }, + ]) + }) + + test("a later shell change updates an earlier write-tool entry", () => { + const messages = [ + assistantMsg([toolPart({ tool: "write", input: { filePath: "/repo/a.sql" }, end: 5000 })]), + assistantMsg([ + toolPart({ tool: "bash", input: { command: "sed -i s/x/y/ a.sql" }, metadata: { exit: 0 }, end: 8000 }), + patchPart(["/repo/a.sql"]), + ]), + ] + const ledger = SessionCompaction.buildLedger(messages, undefined, onDisk(["/repo/a.sql"])) + expect(ledger.writes).toEqual([{ path: "/repo/a.sql", mtime: 8000, tool: "shell" }]) + }) + + test("a listed file that no longer exists is dropped, including an earlier write", () => { + const messages = [ + assistantMsg([toolPart({ tool: "write", input: { filePath: "/repo/tmp.txt" }, end: 5000 })]), + assistantMsg([ + toolPart({ tool: "bash", input: { command: "rm tmp.txt" }, metadata: { exit: 0 }, end: 6000 }), + patchPart(["/repo/tmp.txt"]), + ]), + ] + const ledger = SessionCompaction.buildLedger(messages, undefined, onDisk([])) + expect(ledger.writes).toEqual([]) + }) + + test("without patch parts the shell note stays unverified", () => { + const messages = [ + assistantMsg([toolPart({ tool: "bash", input: { command: "echo hi >> notes.md" }, metadata: { exit: 0 } })]), + ] + const ledger = SessionCompaction.buildLedger(messages, undefined, onDisk(["/repo/notes.md"])) + expect(ledger.writes).toEqual([]) + expect(ledger.sawSnapshot).toBeFalsy() + expect(SessionCompaction.renderLedger(ledger)).toContain("possible but unverified") + }) +}) From abe7ee68afc35abd749353835acfa59a88a1e558 Mon Sep 17 00:00:00 2001 From: RaghwendraSingh Date: Thu, 5 Nov 2026 15:40:27 +0530 Subject: [PATCH 2/3] fix(session): cap snapshot-listed files in the compaction ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single shell step that changed more than ~11-20 files filled the 500-token ledger budget and pushed out the write-tool entries, the shell note, the advisory and the recent tool calls. - Snapshot entries get their own section after write-tool entries, at most 5 paths per step and 15 overall, with "… and N more files changed during your step at …" counts; the list shrinks before the note, advisory or calls drop. - Paths inside the project root render relative to it. - Snapshot entries read "changed during your step at … (snapshot)" and add "via shell" only when that step ran `bash`. - Only write-tool entries (or snapshot entries for a path a write tool wrote earlier) corroborate a carried summary claim as `[verified]`. - The note names files outside the project directory and new files over 2 MB, and says shell changes in steps without a snapshot diff stay unverified. - With snapshot diffs, entries whose file no longer exists are swept once at the end (shell rename, write then delete in one step). - A step with no completed tool uses the message completion time. - `Ledger.sawSnapshot` is required; stale comments updated. Co-Authored-By: Claude Opus 5.5 --- packages/opencode/src/session/compaction.ts | 239 +++++++++++++----- .../test/session/compaction-ledger.test.ts | 179 ++++++++++++- 2 files changed, 350 insertions(+), 68 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index a7b33d99c4..568e06be24 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -581,13 +581,16 @@ export namespace SessionCompaction { // altimate_change start — post-compaction state ledger (5a), // append-only summary carry (5b), first-person summary reframe (5c). // - // 5a: a deterministic, corroborated-facts-only ledger appended to the synthetic - // post-compaction continue message. Facts come from harness tool events ONLY: - // write/edit/apply_patch completion events (path + event timestamp) and the last N - // tool calls with exit codes where recorded. Command-agnostic by design — no - // "build/test" classifier, no vertical (dbt/warehouse) token matching. - // Bash-mediated file changes produce no edit event, so they are flagged as possible - // but unverified rather than guessed at. The re-read directive is advisory and + // 5a: a corroborated-facts-only ledger appended to the synthetic + // post-compaction continue message. Facts come from harness records: + // write/edit/apply_patch completion events (path + event timestamp), the last N + // tool calls with exit codes where recorded, and (altimate_change) per-step + // workspace snapshot diffs, which list files changed during a step however they + // were changed (bash, scripts, other processes). Snapshot entries are labelled as + // such, capped so they cannot crowd out the rest, and never corroborate a carried + // claim. Command-agnostic by design — no "build/test" classifier, no vertical + // (dbt/warehouse) token matching. Shell changes in steps without a snapshot diff + // are flagged as possible but unverified. The re-read directive is advisory and // mtime-anchored, never an absolute prohibition (the model's read-before-edit habit // is load-bearing, and external IDE edits can change disk mid-session). // @@ -600,8 +603,15 @@ export namespace SessionCompaction { export const LEDGER_RECENT_CALLS = 10 // Harness-corroborated write events: tools whose completion PROVES a file write. - // Deliberately excludes bash — shell writes are unverifiable from tool events. + // Deliberately excludes bash — a shell write has no tool event; snapshot diffs + // record it instead, as a `snapshot` entry (altimate_change). const LEDGER_WRITE_TOOLS = new Set(["write", "edit"]) + // altimate_change start — snapshot entries: per-step and overall caps on listed + // paths, so one step that changes many files cannot fill the ledger budget. + const LEDGER_SNAPSHOT = "snapshot" + const LEDGER_SNAPSHOT_PER_STEP = 5 + const LEDGER_SNAPSHOT_MAX = 15 + // altimate_change end const LEDGER_DETAIL_MAX = 100 // A short acknowledgement can steer the live conversation but is not an @@ -821,16 +831,33 @@ export namespace SessionCompaction { return masked.replace(/\b((?:proxy-)?authorization|(?:set-)?cookie)\s*:[\s\S]*$/i, "$1: ") } - export type LedgerWrite = { path: string; mtime: number; tool: string } + export type LedgerWrite = { + path: string + mtime: number + tool: string + // altimate_change start — snapshot entries (`tool: "snapshot"`): the step + // ordinal, whether that step ran bash, and whether a write tool wrote the path + // earlier in the session (only that corroborates a carried claim). + step?: number + shell?: boolean + toolWritten?: boolean + // altimate_change end + } export type LedgerCall = { tool: string detail: string exit?: number | null errored: boolean } - // altimate_change start — `sawSnapshot`: the session recorded per-step snapshot - // diffs, so shell-made changes to tracked and non-ignored files are in `writes`. - export type Ledger = { writes: LedgerWrite[]; calls: LedgerCall[]; sawBash: boolean; sawSnapshot?: boolean } + // altimate_change start — `sawSnapshot`: some steps recorded snapshot diffs, so + // their changes are in `writes`. `root`: the project root paths render against. + export type Ledger = { + writes: LedgerWrite[] + calls: LedgerCall[] + sawBash: boolean + sawSnapshot: boolean + root?: string + } // altimate_change end function callDetail(input: Record | null | undefined): string { @@ -841,7 +868,8 @@ export namespace SessionCompaction { return str.length > LEDGER_DETAIL_MAX ? str.slice(0, LEDGER_DETAIL_MAX) + "…" : str } - /** Deterministic: output depends only on the message list passed in. */ + // altimate_change — output depends on the message list and, for sessions with + // snapshot diffs, on which listed files still exist (`exists`). // altimate_change start — the ledger must be built from the UNFILTERED session // history. The compaction caller passes the compaction-FILTERED view, so from // the second compaction onward every tool event hidden by an earlier @@ -882,28 +910,39 @@ export namespace SessionCompaction { // however it was changed (bash redirects, scripts, `sed -i`), which edit-tool // events alone miss. let sawSnapshot = false + let step = 0 // altimate_change end for (const msg of messages) { - // altimate_change start - const toolWritten = new Set() + // altimate_change start — per-step state, reset at each step's patch part + let toolWritten = new Set() + let stepBash = false let lastEnd: number | undefined // altimate_change end for (const part of msg.parts) { // altimate_change start — a patch part lists the files its step changed. - // Files already recorded by a write/edit tool in this step keep that - // attribution; a listed file that no longer exists was deleted. + // Files a write/edit tool wrote in this step keep that attribution; files + // that no longer exist are swept after the loop. if (part.type === "patch") { sawSnapshot = true - const at = lastEnd ?? msg.info.time?.created ?? 0 + step++ + const time = msg.info.time as { created?: number; completed?: number } | undefined + const at = lastEnd ?? time?.completed ?? time?.created ?? 0 for (const file of part.files) { const key = ledgerPathKey(file, root) if (toolWritten.has(key)) continue - if (!exists(key)) { - writes.delete(key) - continue - } - writes.set(key, { path: file, mtime: at, tool: "shell" }) + const prior = writes.get(key) + writes.set(key, { + path: file, + mtime: at, + tool: LEDGER_SNAPSHOT, + step, + shell: stepBash, + toolWritten: prior !== undefined && (prior.tool !== LEDGER_SNAPSHOT || prior.toolWritten === true), + }) } + toolWritten = new Set() + stepBash = false + lastEnd = undefined continue } // altimate_change end @@ -915,7 +954,10 @@ export namespace SessionCompaction { const exit = typeof metadata.exit === "number" || metadata.exit === null ? metadata.exit : undefined lastEnd = state.time.end // altimate_change — patch-part write time calls.push({ tool: part.tool, detail: callDetail(state.input), exit, errored }) - if (part.tool === "bash") sawBash = true + if (part.tool === "bash") { + sawBash = true + stepBash = true // altimate_change + } if (errored) continue if (LEDGER_WRITE_TOOLS.has(part.tool)) { const filePath = typeof state.input?.filePath === "string" ? state.input.filePath : undefined @@ -952,11 +994,19 @@ export namespace SessionCompaction { } } } + // altimate_change start — with snapshot diffs, drop entries whose file is gone: + // a shell rename lists only the new path, and a file written then deleted in + // one step is skipped by the attribution check above. One check per path. + if (sawSnapshot) for (const key of [...writes.keys()]) if (!exists(key)) writes.delete(key) + // altimate_change end return { writes: [...writes.values()].sort((a, b) => b.mtime - a.mtime || a.path.localeCompare(b.path)), calls, sawBash, - sawSnapshot, // altimate_change + // altimate_change start + sawSnapshot, + root: root ? ledgerPathKey(root) : undefined, + // altimate_change end } } @@ -965,53 +1015,116 @@ export namespace SessionCompaction { * content is ordered by importance (verified writes → unverified-shell note → * advisory → recent calls newest-first) so truncation drops the oldest calls first. */ + // altimate_change start — snapshot entries, newest step first: at most + // LEDGER_SNAPSHOT_PER_STEP paths per step and `limit` overall, with counts of + // the rest, so a formatter or generator run cannot fill the ledger budget. + function snapshotLines(entries: LedgerWrite[], limit: number, display: (w: LedgerWrite) => string): string[] { + const steps = new Map() + for (const w of entries) { + const group = steps.get(w.step ?? 0) + if (group) group.push(w) + else steps.set(w.step ?? 0, [w]) + } + const out: string[] = [] + let shown = 0 + let hiddenFiles = 0 + let hiddenSteps = 0 + for (const group of steps.values()) { + const take = Math.min(group.length, LEDGER_SNAPSHOT_PER_STEP, limit - shown) + if (take <= 0) { + hiddenFiles += group.length + hiddenSteps++ + continue + } + const at = new Date(group[0]!.mtime).toISOString() + const how = group[0]!.shell ? "snapshot, via shell" : "snapshot" + for (const w of group.slice(0, take)) out.push(`- ${display(w)} — changed during your step at ${at} (${how})`) + shown += take + if (group.length > take) + out.push(`- … and ${group.length - take} more files changed during your step at ${at} (${how})`) + } + if (hiddenFiles) + out.push( + `- … and ${hiddenFiles} more files changed during ${hiddenSteps} earlier step${hiddenSteps === 1 ? "" : "s"} (snapshot)`, + ) + return out + } + // altimate_change end + export function renderLedger(ledger: Ledger, opts?: { maxTokens?: number; recentCalls?: number }): string { const maxTokens = opts?.maxTokens ?? LEDGER_MAX_TOKENS const recentCalls = opts?.recentCalls ?? LEDGER_RECENT_CALLS if (!ledger.writes.length && !ledger.calls.length) return "" - const lines: string[] = ["[Session state ledger — harness-recorded facts, generated automatically at compaction]"] - if (ledger.writes.length) { - // altimate_change — includes files changed by shell commands, from snapshot diffs - lines.push("Files you wrote this session (verified by write/edit tool events or workspace snapshot diffs):") - for (const w of ledger.writes) { + // altimate_change start — tool-written entries first, then capped snapshot + // entries; paths inside the project root render relative to it. + const root = ledger.root + const display = (w: LedgerWrite) => { + const key = ledgerPathKey(w.path, root) + const rel = root ? path.posix.relative(root, key) : "" + return redactLedgerDetail( + rel && !rel.startsWith("../") && rel !== ".." && !path.posix.isAbsolute(rel) ? rel : w.path, + ) + } + const toolWrites = ledger.writes.filter((w) => w.tool !== LEDGER_SNAPSHOT) + const snapshotWrites = ledger.writes.filter((w) => w.tool === LEDGER_SNAPSHOT) + const compose = (snapshotLimit: number): string[] => { + const lines: string[] = ["[Session state ledger — harness-recorded facts, generated automatically at compaction]"] + if (toolWrites.length) { lines.push( - `- ${redactLedgerDetail(w.path)} — last written by you at ${new Date(w.mtime).toISOString()} via ${w.tool}`, + root + ? "Files you wrote this session (verified write/edit tool events; paths relative to the project root):" + : "Files you wrote this session (verified write/edit tool events):", + ) + for (const w of toolWrites) { + lines.push(`- ${display(w)} — last written by you at ${new Date(w.mtime).toISOString()} via ${w.tool}`) + } + } + if (snapshotWrites.length) { + lines.push( + "Files changed in the project during your steps (workspace snapshot diffs; may include changes by other processes such as IDE saves or formatters):", + ) + lines.push(...snapshotLines(snapshotWrites, snapshotLimit, display)) + } + if (ledger.sawSnapshot && (ledger.sawBash || snapshotWrites.length)) { + lines.push( + "Workspace snapshot diffs list files changed during the steps that recorded one, including shell changes; they omit git-ignored files, files outside the project directory, and new files over 2 MB. Shell changes in steps without a snapshot diff are possible but unverified.", + ) + } else if (ledger.sawBash) { + lines.push( + "Shell commands also ran this session; any file changes they made are possible but unverified (shell writes produce no edit event).", ) } - } - // altimate_change start — with snapshot diffs, shell changes are listed above - // except where the snapshot does not look: git-ignored files and files over 2 MB. - if (ledger.sawBash && ledger.sawSnapshot) { - lines.push( - "Files changed by shell commands are listed above (via shell); changes to git-ignored files or files over 2 MB are not tracked.", - ) - } else if (ledger.sawBash) { lines.push( - "Shell commands also ran this session; any file changes they made are possible but unverified (shell writes produce no edit event).", + "Advisory: these files were last written by you at the times shown — prefer this ledger over re-reading them; re-read a file only if a tool errored, you suspect external changes (e.g. IDE edits), or you are about to edit it.", ) - } - // altimate_change end - lines.push( - "Advisory: these files were last written by you at the times shown — prefer this ledger over re-reading them; re-read a file only if a tool errored, you suspect external changes (e.g. IDE edits), or you are about to edit it.", - ) - // altimate_change start — `slice(-0)` is `slice(0)`, i.e. EVERY call, so a - // configured `ledger_recent_calls: 0` (which the NonNegativeInt schema - // accepts) rendered the entire call history instead of none. - if (ledger.calls.length && recentCalls > 0) { - const recent = ledger.calls.slice(-recentCalls).reverse() - // altimate_change end - lines.push(`Recent tool calls, newest first (last ${recent.length} of ${ledger.calls.length}):`) - for (const c of recent) { - const status = c.errored - ? "errored" - : c.exit === undefined - ? "ok" - : c.exit === null - ? "exit ?" - : `exit ${c.exit}` - lines.push(`- ${c.tool} (${status})${c.detail ? ` — ${c.detail}` : ""}`) + // altimate_change start — `slice(-0)` is `slice(0)`, i.e. EVERY call, so a + // configured `ledger_recent_calls: 0` (which the NonNegativeInt schema + // accepts) rendered the entire call history instead of none. + if (ledger.calls.length && recentCalls > 0) { + const recent = ledger.calls.slice(-recentCalls).reverse() + // altimate_change end + lines.push(`Recent tool calls, newest first (last ${recent.length} of ${ledger.calls.length}):`) + for (const c of recent) { + const status = c.errored + ? "errored" + : c.exit === undefined + ? "ok" + : c.exit === null + ? "exit ?" + : `exit ${c.exit}` + lines.push(`- ${c.tool} (${status})${c.detail ? ` — ${c.detail}` : ""}`) + } } + return lines } + // Fewer snapshot paths first: the note, advisory and recent calls outrank + // a long list of snapshot-listed files. + for (let limit = Math.min(LEDGER_SNAPSHOT_MAX, snapshotWrites.length); limit > 0; limit--) { + const text = compose(limit).join("\n") + if (Token.estimate(text) <= maxTokens) return text + } + const lines = compose(0) + // altimate_change end // Find the longest fitting prefix in O(n log n); repeatedly joining after // one pop made a many-file ledger quadratic on the recovery hot path. let low = 1 @@ -1096,6 +1209,8 @@ export namespace SessionCompaction { // append-only that wrong [verified] fact survives every later compaction. const base = token.includes("/") ? "" : token for (const w of ledger.writes) { + // altimate_change — a snapshot entry alone is not proof the agent wrote it + if (w.tool === LEDGER_SNAPSHOT && !w.toolWritten) continue if (w.path === token || w.path.endsWith("/" + token)) return true if (base && w.path.split("/").pop() === base) return true } @@ -1446,7 +1561,7 @@ export namespace SessionCompaction { const ledger: Ledger = ledgerEnabled || carryEnabled ? buildLedger(input.unfilteredMessages ?? ledgerHistory(input.sessionID, input.messages), Instance.directory) - : { writes: [], calls: [], sawBash: false } + : { writes: [], calls: [], sawBash: false, sawSnapshot: false } // altimate_change end const history = compactionPart && messages.at(-1)?.info.id === input.parentID ? messages.slice(0, -1) : messages const prior = completedCompactions(history) diff --git a/packages/opencode/test/session/compaction-ledger.test.ts b/packages/opencode/test/session/compaction-ledger.test.ts index 0cc7af67d1..a767afeb1b 100644 --- a/packages/opencode/test/session/compaction-ledger.test.ts +++ b/packages/opencode/test/session/compaction-ledger.test.ts @@ -280,7 +280,7 @@ describe("SessionCompaction.renderLedger", () => { ]) test("empty ledger renders empty string", () => { - expect(SessionCompaction.renderLedger({ writes: [], calls: [], sawBash: false })).toBe("") + expect(SessionCompaction.renderLedger({ writes: [], calls: [], sawBash: false, sawSnapshot: false })).toBe("") }) test("a budget too small for even the header renders nothing, not a bare header", () => { @@ -617,6 +617,7 @@ describe("SessionCompaction.corroborateCarry", () => { writes: [], calls: [{ tool: "bash", detail: "rm report.csv", exit: 0, errored: false }], sawBash: true, + sawSnapshot: false, } expect(SessionCompaction.corroborateCarry([{ text: "exported report.csv" }], destructive)[0]!.status).toBe( "claimed, unverified", @@ -749,12 +750,14 @@ describe("SessionCompaction.buildLedger — snapshot diffs", () => { ]), ] const ledger = SessionCompaction.buildLedger(messages, undefined, onDisk(["/repo/notes.md"])) - expect(ledger.writes).toEqual([{ path: "/repo/notes.md", mtime: 7000, tool: "shell" }]) + expect(ledger.writes).toEqual([ + { path: "/repo/notes.md", mtime: 7000, tool: "snapshot", step: 1, shell: true, toolWritten: false }, + ]) expect(ledger.sawSnapshot).toBe(true) const text = SessionCompaction.renderLedger(ledger) expect(text).toContain("/repo/notes.md") expect(text).toContain("via shell") - expect(text).not.toContain("possible but unverified") + expect(text).not.toContain("Shell commands also ran this session") }) test("a file written by a write tool in the same step keeps the tool attribution", () => { @@ -767,7 +770,7 @@ describe("SessionCompaction.buildLedger — snapshot diffs", () => { ] const ledger = SessionCompaction.buildLedger(messages, undefined, onDisk(["/repo/a.sql", "/repo/out/report.md"])) expect(ledger.writes).toEqual([ - { path: "/repo/out/report.md", mtime: 6000, tool: "shell" }, + { path: "/repo/out/report.md", mtime: 6000, tool: "snapshot", step: 1, shell: true, toolWritten: false }, { path: "/repo/a.sql", mtime: 5000, tool: "write" }, ]) }) @@ -781,7 +784,9 @@ describe("SessionCompaction.buildLedger — snapshot diffs", () => { ]), ] const ledger = SessionCompaction.buildLedger(messages, undefined, onDisk(["/repo/a.sql"])) - expect(ledger.writes).toEqual([{ path: "/repo/a.sql", mtime: 8000, tool: "shell" }]) + expect(ledger.writes).toEqual([ + { path: "/repo/a.sql", mtime: 8000, tool: "snapshot", step: 1, shell: true, toolWritten: true }, + ]) }) test("a listed file that no longer exists is dropped, including an earlier write", () => { @@ -802,7 +807,169 @@ describe("SessionCompaction.buildLedger — snapshot diffs", () => { ] const ledger = SessionCompaction.buildLedger(messages, undefined, onDisk(["/repo/notes.md"])) expect(ledger.writes).toEqual([]) - expect(ledger.sawSnapshot).toBeFalsy() + expect(ledger.sawSnapshot).toBe(false) expect(SessionCompaction.renderLedger(ledger)).toContain("possible but unverified") }) }) + +// ─── Snapshot diffs: review follow-ups ─────────────────────────────────────── + +describe("SessionCompaction.renderLedger — snapshot entries cannot crowd out the ledger", () => { + test("120 shell-changed files keep the write-tool entry, the note, the advisory and the recent calls", () => { + const shellFiles = Array.from( + { length: 120 }, + (_, i) => `/repo/models/staging/stg_model_${String(i).padStart(3, "0")}.sql`, + ) + const messages = [ + assistantMsg([toolPart({ tool: "write", input: { filePath: "/repo/reports/summary.md" }, end: 5000 })]), + assistantMsg([ + toolPart({ tool: "bash", input: { command: "sqlfluff fix models" }, metadata: { exit: 0 }, end: 9000 }), + patchPart(shellFiles), + ]), + ] + const ledger = SessionCompaction.buildLedger(messages, "/repo", () => true) + const text = SessionCompaction.renderLedger(ledger, { maxTokens: 500 }) + expect(Token.estimate(text)).toBeLessThanOrEqual(500) + expect(text).toContain("reports/summary.md") + expect(text).toContain("files outside the project directory") + expect(text).toContain("Advisory:") + expect(text).toContain("Recent tool calls") + expect(text).toContain("sqlfluff fix models") + expect(text).toMatch(/and 11\d more files changed during your step/) + // Write-tool entries come before snapshot entries. + expect(text.indexOf("reports/summary.md")).toBeLessThan(text.indexOf("stg_model_")) + }) + + test("snapshot entries across many steps are capped overall with a count of the rest", () => { + const messages = Array.from({ length: 30 }, (_, step) => + assistantMsg([ + toolPart({ tool: "bash", input: { command: `gen ${step}` }, metadata: { exit: 0 }, end: 1000 + step }), + patchPart([`/repo/out/a_${step}.sql`, `/repo/out/b_${step}.sql`]), + ]), + ) + const ledger = SessionCompaction.buildLedger(messages, "/repo", () => true) + const text = SessionCompaction.renderLedger(ledger, { maxTokens: 2000 }) + const listed = text.split("\n").filter((line) => line.startsWith("- out/")) + expect(listed.length).toBeLessThan(60) + expect(text).toMatch(/and \d+ more files changed during \d+ earlier steps/) + }) + + test("paths inside the project root render relative to it", () => { + const messages = [ + assistantMsg([toolPart({ tool: "write", input: { filePath: "/repo/src/a.ts" }, end: 5000 })]), + assistantMsg([ + toolPart({ tool: "bash", input: { command: "x" }, end: 6000 }), + patchPart(["/repo/src/b.ts", "/elsewhere/c.ts"]), + ]), + ] + const text = SessionCompaction.renderLedger(SessionCompaction.buildLedger(messages, "/repo", () => true)) + expect(text).toContain("- src/a.ts — last written by you") + expect(text).toContain("- src/b.ts — changed during your step") + expect(text).toContain("- /elsewhere/c.ts — changed during your step") + expect(text).not.toMatch(/^- \/repo\/src\//m) + }) +}) + +describe("SessionCompaction ledger — snapshot entry wording and lifecycle", () => { + test("a patch-only step without bash reads as changed during the step, not written by you via shell", () => { + const messages = [ + assistantMsg([ + toolPart({ tool: "read", input: { filePath: "/r/x.md" }, end: 4000 }), + patchPart(["/r/secrets/acquisition.md"]), + ]), + ] + const text = SessionCompaction.renderLedger(SessionCompaction.buildLedger(messages, undefined, () => true)) + const line = text.split("\n").find((l) => l.includes("acquisition.md"))! + expect(line).toContain("changed during your step at") + expect(line).toContain("(snapshot)") + expect(line).not.toContain("via shell") + expect(line).not.toContain("last written by you") + }) + + test("a step that ran bash says via shell", () => { + const messages = [ + assistantMsg([ + toolPart({ tool: "bash", input: { command: "python scan.py" }, end: 4000 }), + patchPart(["/r/out.md"]), + ]), + ] + const text = SessionCompaction.renderLedger(SessionCompaction.buildLedger(messages, undefined, () => true)) + expect(text.split("\n").find((l) => l.includes("/r/out.md"))).toContain("(snapshot, via shell)") + }) + + test("the snapshot note names files outside the project directory and new files over 2 MB", () => { + const messages = [ + assistantMsg([ + toolPart({ tool: "bash", input: { command: "python scan.py > /tmp/scan.md" }, end: 4000 }), + patchPart(["/r/a.md"]), + ]), + ] + const text = SessionCompaction.renderLedger(SessionCompaction.buildLedger(messages, undefined, () => true)) + expect(text).toContain("files outside the project directory") + expect(text).toContain("new files over 2 MB") + expect(text).toContain("steps without a snapshot diff") + }) + + test("a shell rename drops the old path the patch no longer lists", () => { + const messages = [ + assistantMsg([toolPart({ tool: "write", input: { filePath: "/r/old.ts" }, end: 4000 })]), + assistantMsg([ + toolPart({ tool: "bash", input: { command: "mv old.ts new.ts" }, end: 5000 }), + patchPart(["/r/new.ts"]), + ]), + ] + const ledger = SessionCompaction.buildLedger(messages, undefined, (f) => f === "/r/new.ts") + expect(ledger.writes.map((w) => w.path)).toEqual(["/r/new.ts"]) + }) + + test("a file written and deleted in the same step is not listed", () => { + const messages = [ + assistantMsg([ + toolPart({ tool: "write", input: { filePath: "/r/tmp.txt" }, end: 4000 }), + toolPart({ tool: "bash", input: { command: "rm /r/tmp.txt" }, end: 5000 }), + patchPart(["/r/tmp.txt"]), + ]), + ] + const ledger = SessionCompaction.buildLedger(messages, undefined, () => false) + expect(ledger.writes).toEqual([]) + }) + + test("a step with no completed tool uses the message completion time", () => { + const messages = [assistantMsg([patchPart(["/r/a.md"])], { time: { created: 1000, completed: 3000 } })] + const ledger = SessionCompaction.buildLedger(messages, undefined, () => true) + expect(ledger.writes[0]?.mtime).toBe(3000) + }) +}) + +describe("SessionCompaction.corroborateCarry — snapshot entries", () => { + test("a patch-only entry does not promote a carried claim to verified", () => { + const ledger = SessionCompaction.buildLedger( + [ + assistantMsg([ + toolPart({ tool: "bash", input: { command: "x" }, end: 4000 }), + patchPart(["/r/secrets/acquisition.md"]), + ]), + ], + undefined, + () => true, + ) + const [item] = SessionCompaction.corroborateCarry([{ text: "Wrote acquisition.md" }], ledger) + expect(item?.status).toBe("claimed, unverified") + }) + + test("a write-tool file later changed by shell still corroborates", () => { + const ledger = SessionCompaction.buildLedger( + [ + assistantMsg([toolPart({ tool: "write", input: { filePath: "/r/report.md" }, end: 4000 })]), + assistantMsg([ + toolPart({ tool: "bash", input: { command: "sed -i s/a/b/ report.md" }, end: 5000 }), + patchPart(["/r/report.md"]), + ]), + ], + undefined, + () => true, + ) + const [item] = SessionCompaction.corroborateCarry([{ text: "Wrote report.md" }], ledger) + expect(item?.status).toBe("verified") + }) +}) From 282ab3995a395a036fe238ccbca648a3dcc7a68b Mon Sep 17 00:00:00 2001 From: RaghwendraSingh Date: Thu, 5 Nov 2026 15:40:27 +0530 Subject: [PATCH 3/3] test(session): prompt-loop test for one summary per compaction Replaces the source-text guard with a behavioural test: a completed tail-keeping compaction is seeded and `prompt.loop` runs on a mock provider. The loop must go to the agent with one compaction user and one summary. Without the `lastFinished` selection by id, it writes a second compaction. The two `filterCompacted`/`latest` unit tests duplicated `message-v2.test.ts` and are removed. Co-Authored-By: Claude Opus 5.5 --- .../session/compaction-double-summary.test.ts | 246 ++++++++++++++---- 1 file changed, 195 insertions(+), 51 deletions(-) diff --git a/packages/opencode/test/session/compaction-double-summary.test.ts b/packages/opencode/test/session/compaction-double-summary.test.ts index 17d2102e4d..f8e7018b4f 100644 --- a/packages/opencode/test/session/compaction-double-summary.test.ts +++ b/packages/opencode/test/session/compaction-double-summary.test.ts @@ -1,53 +1,197 @@ -import { describe, expect, test } from "bun:test" -import { readFileSync } from "node:fs" -import path from "node:path" -import { MessageV2 } from "../../src/session/message-v2" -import { MessageID } from "../../src/session/schema" - -// After a compaction that keeps a tail, filterCompacted renders -// [compaction, summary, ...tail, continue]. The prompt loop's overflow check -// must read the summary, not the last tail assistant, whose recorded usage is -// the pre-compaction context; otherwise it compacts again before any request. - -const TAIL_USER = MessageID.make("msg_001") -const OVERFLOW_ASSISTANT = MessageID.make("msg_002") -const COMPACTION_USER = MessageID.make("msg_003") -const SUMMARY_ASSISTANT = MessageID.make("msg_004") -const CONTINUE_USER = MessageID.make("msg_005") - -const tokens = (input: number) => ({ input, output: 100, reasoning: 0, cache: { read: 0, write: 0 }, total: input + 100 }) -const user = (id: string, parts: any[]) => ({ info: { id, role: "user", sessionID: "s" }, parts }) -const assistant = (id: string, parentID: string, extra: Record) => ({ - info: { id, role: "assistant", sessionID: "s", parentID, ...extra }, - parts: [], -}) +/** + * After a compaction that keeps a tail, the next loop iteration must go straight to the + * agent: the retained pre-compaction assistant still carries its overflowing usage, and + * reading it as the latest finished turn compacted a second time from the tail alone. + */ +import { expect } from "bun:test" +import { Effect, Layer } from "effect" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { Session } from "@/session/session" +import { SessionPrompt } from "../../src/session/prompt" +import { SessionSummary } from "../../src/session/summary" +import { MessageID, PartID } from "../../src/session/schema" +import { Database } from "@opencode-ai/core/database/database" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { testEffect } from "../lib/effect" +import { TestLLMServer } from "../lib/llm-server" +import { provideTmpdirServerLegacy } from "./legacy-instance" +import { LSP } from "@/lsp/lsp" +import { MCP } from "../../src/mcp" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { RuntimeFlags } from "@/effect/runtime-flags" + +const mcp = Layer.succeed( + MCP.Service, + MCP.Service.of({ + status: () => Effect.succeed({}), + clients: () => Effect.succeed({}), + tools: () => Effect.succeed({}), + prompts: () => Effect.succeed({}), + resources: () => Effect.succeed({}), + add: () => Effect.succeed({ status: { status: "disabled" as const } }), + connect: () => Effect.void, + disconnect: () => Effect.void, + remove: () => Effect.void, + getPrompt: () => Effect.succeed(undefined), + readResource: () => Effect.succeed(undefined), + startAuth: () => Effect.die("unexpected MCP auth"), + authenticate: () => Effect.die("unexpected MCP auth"), + finishAuth: () => Effect.die("unexpected MCP auth"), + removeAuth: () => Effect.void, + supportsOAuth: () => Effect.succeed(false), + hasStoredTokens: () => Effect.succeed(false), + getAuthStatus: () => Effect.succeed("not_authenticated" as const), + entry: () => Effect.succeed(undefined), + listMeta: () => Effect.succeed(undefined), + snapshot: () => Effect.succeed({ tools: {}, meta: undefined }), + }), +) + +const lsp = Layer.succeed( + LSP.Service, + LSP.Service.of({ + init: () => Effect.void, + status: () => Effect.succeed([]), + hasClients: () => Effect.succeed(false), + touchFile: () => Effect.void, + diagnostics: () => Effect.succeed({}), + hover: () => Effect.succeed(undefined), + definition: () => Effect.succeed([]), + references: () => Effect.succeed([]), + implementation: () => Effect.succeed([]), + documentSymbol: () => Effect.succeed([]), + workspaceSymbol: () => Effect.succeed([]), + prepareCallHierarchy: () => Effect.succeed([]), + incomingCalls: () => Effect.succeed([]), + outgoingCalls: () => Effect.succeed([]), + }), +) -const newestFirst = [ - user(CONTINUE_USER, [{ type: "text", text: "Continue", synthetic: true }]), - assistant(SUMMARY_ASSISTANT, COMPACTION_USER, { summary: true, finish: "stop", tokens: tokens(150_000) }), - user(COMPACTION_USER, [{ type: "compaction", auto: true, tail_start_id: TAIL_USER }]), - assistant(OVERFLOW_ASSISTANT, TAIL_USER, { finish: "tool-calls", tokens: tokens(180_000) }), - user(TAIL_USER, [{ type: "text", text: "continue the task" }]), -] as unknown as MessageV2.WithParts[] - -describe("post-compaction overflow check", () => { - test("the array-last finished assistant is the retained pre-compaction turn", () => { - const msgs = MessageV2.filterCompacted(newestFirst) - const arrayLast = msgs.findLast((m) => m.info.role === "assistant" && (m.info as MessageV2.Assistant).finish) - expect(arrayLast?.info.id).toBe(OVERFLOW_ASSISTANT) - }) - - test("selection by id lands on the summary, which the overflow check skips", () => { - const finished = MessageV2.latest(MessageV2.filterCompacted(newestFirst)).finished - expect(finished?.id).toBe(SUMMARY_ASSISTANT) - expect(finished?.summary).toBe(true) - }) - - test("the prompt loop selects lastFinished by id before the overflow check", () => { - const source = readFileSync(path.join(import.meta.dir, "../../src/session/prompt.ts"), "utf8") - const select = source.indexOf("lastFinished = MessageV2.latest(msgs).finished") - const check = source.indexOf("estimateUncountedTail(msgs, lastFinished?.id)") - expect(select).toBeGreaterThan(0) - expect(check).toBeGreaterThan(select) - }) +const root = LayerNode.group([ + SessionPrompt.node, + Session.node, + SessionProjector.node, + SessionSummary.node, + Database.node, + CrossSpawnSpawner.node, + LayerNode.make(TestLLMServer.layer, []), +]) +const it = testEffect( + LayerNode.buildLayer(root, { + replacements: [ + LayerNode.replace(MCP.node, mcp), + LayerNode.replace(LSP.node, lsp), + LayerNode.replace(RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })), + ], + }), +) + +const providerCfg = (url: string) => ({ + provider: { + "altimate-backend": { + name: "Altimate (test)", + id: "altimate-backend", + env: [], + npm: "@ai-sdk/openai-compatible", + models: { + "test-model": { + id: "test-model", + name: "Test Model", + attachment: false, + reasoning: false, + temperature: false, + tool_call: true, + release_date: "2025-01-01", + limit: { context: 100000, output: 10000 }, + cost: { input: 0, output: 0 }, + options: {}, + }, + }, + options: { apiKey: "test-key", baseURL: url }, + }, + }, + model: "altimate-backend/test-model", }) + +const model = { providerID: "altimate-backend", modelID: "test-model" } as any +const usage = (input: number) => ({ input, output: 100, reasoning: 0, cache: { read: 0, write: 0 } }) + +it.live( + "a completed tail-keeping compaction is followed by the agent request, not a second summary", + () => + provideTmpdirServerLegacy( + Effect.fnUntraced(function* ({ dir, llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + title: "compacted once", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + const sessionID = session.id + const user = Effect.fnUntraced(function* (part: Record) { + const msg = yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + agent: "build", + model, + time: { created: Date.now() }, + } as any) + yield* sessions.updatePart({ id: PartID.ascending(), messageID: msg.id, sessionID, ...part } as any) + return msg + }) + const assistant = Effect.fnUntraced(function* (parentID: MessageID, input: number, extra: Record) { + const msg = yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "assistant", + sessionID, + mode: "build", + agent: "build", + path: { cwd: dir, root: dir }, + cost: 0, + tokens: usage(input), + modelID: model.modelID, + providerID: model.providerID, + parentID, + time: { created: Date.now(), completed: Date.now() }, + ...extra, + } as any) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID, + type: "text", + text: extra.summary ? "summary of the work so far" : "working", + } as any) + return msg + }) + + // [tail user, overflowing assistant] -> compaction keeping that tail -> summary -> continue. + const tail = yield* user({ type: "text", text: "continue the task" }) + yield* assistant(tail.id, 95_000, { finish: "stop" }) + const compaction = yield* user({ type: "compaction", auto: true, tail_start_id: tail.id }) + yield* assistant(compaction.id, 2_000, { + summary: true, + finish: "stop", + mode: "compaction", + agent: "compaction", + }) + yield* user({ type: "text", text: "Continue if you have next steps.", synthetic: true }) + + yield* llm.text("agent reply after compaction") + yield* llm.text("agent reply after compaction") + yield* prompt.loop({ sessionID }) + + const messages = yield* sessions.messages({ sessionID }) + const compactionUsers = messages.filter((m) => m.parts.some((part) => part.type === "compaction")) + const summaries = messages.filter((m) => m.info.role === "assistant" && (m.info as any).summary === true) + expect(compactionUsers.length).toBe(1) + expect(summaries.length).toBe(1) + const hits = yield* llm.hits + expect(hits.length).toBeGreaterThan(0) + expect(JSON.stringify(hits[0]!.body)).not.toContain("summary of the conversation") + }), + { git: true, config: providerCfg }, + ), + { timeout: 30_000 }, +)