diff --git a/.gitignore b/.gitignore index ee0809b00..f2caa4f7c 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,7 @@ tsconfig.tsbuildinfo .opencode/dag-init.json .opencode/workflow-drafts/ .opencode/workflow-reports/ +# >>> specgit: local delivery assets (managed by specgit init) >>> +/.specgit.yaml +/spec_git/ +# <<< specgit: local delivery assets (managed by specgit init) <<< diff --git a/.opencode/command/specgit-doctor.md b/.opencode/command/specgit-doctor.md new file mode 100644 index 000000000..e078b5431 --- /dev/null +++ b/.opencode/command/specgit-doctor.md @@ -0,0 +1,26 @@ +--- +description: Diagnose the SpecGit environment probes and drive the exit-3 repair loop +--- + + + +# /specgit-doctor + +Thin trigger for the exit-3 diagnostic loop. The canonical behavior lives in +the AGENTS.md SpecGit block; this command only launches it. + +## Steps + +1. Run from the repo root: + + ```bash + specgit doctor --json + ``` + +2. Read `probes[]`: every failing probe carries a `code` (git, repo, + origin, gh/glab presence and auth, policy). +3. Fix exactly what the failing probe names, then re-run + `specgit doctor --json` until exit 0. +4. Return to the verdict: `specgit finish --json`. Exit 3 is environment, + never delivery — do not edit the record or the policy to work around it. +5. `--json` is the only parse surface. diff --git a/.opencode/command/specgit-finish.md b/.opencode/command/specgit-finish.md index 63e86e2cc..e828bcdd5 100644 --- a/.opencode/command/specgit-finish.md +++ b/.opencode/command/specgit-finish.md @@ -2,6 +2,8 @@ description: Run the SpecGit evidence verdict and drive the fix loop to exit 0 --- + + # /specgit-finish Thin trigger for the acceptance verdict. The canonical behavior lives in the diff --git a/.opencode/command/specgit-issue.md b/.opencode/command/specgit-issue.md index 6ec61c6e5..aff9adebd 100644 --- a/.opencode/command/specgit-issue.md +++ b/.opencode/command/specgit-issue.md @@ -2,6 +2,8 @@ description: Start a SpecGit delivery from a title or existing issue number --- + + # /specgit-issue Thin trigger for the delivery bootstrap. The canonical behavior lives in the @@ -17,6 +19,11 @@ AGENTS.md SpecGit block; this command only launches it. specgit issue "$ARGUMENTS" --json ``` -3. On success report the brief: issue URL(s), PR URL (draft), branch name. +3. On success report the brief: issue URL(s), PR URL (draft), branch name — + then fill each issue body it created (Why / Scope / Approach / + Acceptance) from the discussion with `gh issue edit `, then + implement. Fill in the draft PR's scaffold (Why / What changed / + Evidence) as you deliver; its placeholders are advisory, never gates, + and the closing references stay intact. 4. Switch to the delivery branch and begin the TDD loop. 5. On error, read `errors[].fix` and follow it — never bypass the record. diff --git a/.opencode/command/specgit-pr.md b/.opencode/command/specgit-pr.md new file mode 100644 index 000000000..63150c805 --- /dev/null +++ b/.opencode/command/specgit-pr.md @@ -0,0 +1,27 @@ +--- +description: Repair the SpecGit PR binding — auto-discover by head branch or bind explicitly +--- + + + +# /specgit-pr + +Thin trigger for PR-binding repair. The canonical behavior lives in the +AGENTS.md SpecGit block; this command only launches it. + +## Steps + +1. Run from the delivery branch: + + ```bash + specgit pr --json + ``` + +2. Branch on the result: + - `exit 0` → the record's PR binding is repaired; resume the delivery. + - `pr_not_found` → push the branch (re-running `specgit issue` + resumes the bootstrap), then rerun this command. + - `pr_ambiguous` → several open PRs share the head branch; bind one + explicitly: `specgit pr `. +3. `specgit pr` owns the PR binding; never hand-edit `.specgit.yaml`. + `--json` is the only parse surface. diff --git a/.opencode/command/specgit-status.md b/.opencode/command/specgit-status.md new file mode 100644 index 000000000..0fb8a69c0 --- /dev/null +++ b/.opencode/command/specgit-status.md @@ -0,0 +1,28 @@ +--- +description: Show local SpecGit evidence — record, delivery state, drift, origin +--- + + + +# /specgit-status + +Thin trigger for local evidence. The canonical behavior lives in the +AGENTS.md SpecGit block; this command only launches it. + +## Steps + +1. Run from the repo root: + + ```bash + specgit status --json + ``` + +2. Read `state` and `record` from the envelope: local evidence only — + record, drift, origin. Platform evidence (issues, PR, checks) belongs + to `specgit finish`. +3. No record is not an error: `state: "unbound"` with exit `0` is the + normal pre-binding state — bootstrap with `specgit issue` (the + `record_missing` warning carries the next step in `warnings[].fix`). + Exit `3` is different: `state: "unknown"`, a genuine evidence + failure — read `errors[].fix`. +4. Never hand-edit `.specgit.yaml`. diff --git a/.opencode/hooks/specgit-merge-guard.sh b/.opencode/hooks/specgit-merge-guard.sh index ad407724b..f261123f3 100755 --- a/.opencode/hooks/specgit-merge-guard.sh +++ b/.opencode/hooks/specgit-merge-guard.sh @@ -1,5 +1,5 @@ #!/bin/sh -# SpecGit merge guard (managed by specgit init). Exit 2 = block with reason. +# SpecGit guard (managed by specgit init): start gate + merge guard. Exit 2 = block with reason. GUARD_DIR=$(cd "$(dirname "$0")" && pwd) export GUARD_DIR # Hook payloads arrive as the first argument or on stdin; accept both. @@ -8,6 +8,21 @@ if [ -n "$1" ]; then else payload=$(cat) fi +tool=$(printf '%s' "$payload" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{const j=JSON.parse(s);process.stdout.write((j.tool_name)||'')}catch{process.stdout.write('')}})") +case "$tool" in + edit|write|Edit|Write) + # Start gate (#335): mutating files requires the delivery binding on + # THIS branch. The record's context.branch is written by specgit and + # matched as a fixed WHOLE line — no YAML parsing, no prefix collision + # (branch "feat/1-a" must never satisfy a record for "feat/1-a2"). + branch=$(git branch --show-current 2>/dev/null) + if [ -z "$branch" ] || [ ! -f .specgit.yaml ] || ! grep -qFx " branch: $branch" .specgit.yaml; then + echo "specgit: start gate - this branch has no delivery binding. Start the delivery first: specgit issue \": \", then fill each issue body from the discussion, then edit files." >&2 + exit 2 + fi + exit 0 + ;; +esac command=$(printf '%s' "$payload" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{const j=JSON.parse(s);process.stdout.write((j.tool_input&&j.tool_input.command)||'')}catch{process.stdout.write('')}})") case "$command" in diff --git a/.specgit.yaml b/.specgit.yaml index fb943af14..da464b8fa 100644 --- a/.specgit.yaml +++ b/.specgit.yaml @@ -1,8 +1,8 @@ version: 1 -delivery: ad-hoc-sign +delivery: giant-summary-guard context: kind: branch - branch: fix/454-ad-hoc-sign + branch: feat/458-giant-summary-guard issues: - - 454 -pr: 455 + - 458 +pr: 459 diff --git a/AGENTS.md b/AGENTS.md index c08d5319d..1287a9e4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -267,6 +267,7 @@ This repository uses a multi-context domain-document layout rooted at `CONTEXT-M Kept OUTSIDE the managed block so `specgit init`/`--force` never rewrites them; re-apply each deviation after every re-init: - `specgit-accept.yml` drops the template's `workflow_dispatch` trigger. Dispatch is the privileged context that fires CodeQL's cache-poisoning taint rule on the `head_ref` checkout (false positive: no cache use, read-only token, `persist-credentials: false`), and on dispatch events `head_ref` is empty so the verdict would evaluate the default branch — the wrong tree. Delivery here always goes through a PR. The head-ref checkout itself must NOT be replaced with a SHA: `specgit finish` requires HEAD on the delivery branch (detached_head otherwise). +- `specgit-accept.yml` installs the CLI globally (`npm install -g specgit@^0.5.0`), NOT the template's `npm install --no-save specgit@X`: a workspace-local install reads this bun workspace's package.json and dies on the `catalog:` protocol (EUNSUPPORTEDPROTOCOL, #434, again on #459). Re-apply after every `specgit init --force`. - `spec_git/policy.yaml` `required_checks` uses the template's canonical check IDs (`unit-tests`, `e2e-tests`), not display names. <!-- specgit:block:start --> @@ -283,13 +284,30 @@ already exists); keep manual guidance outside them. deterministic scaffold (the `Closes #n` line for every bound issue, then Why / What changed / Evidence / Checklist sections), and writes `.specgit.yaml`. Re-running resumes; it is idempotent. -- Fill in the scaffold sections as you deliver. Its placeholders are - advisory — the closing references are the only body gate. The PR body +- Issue bodies are filled at bootstrap, from the conversation: right after + `specgit issue` succeeds, edit each issue it created (`gh issue edit <n>`) + with the discussed Why / Scope / Approach / Acceptance, then implement. + The PR scaffold's placeholders are advisory — fill those sections in as + you deliver; the closing references are the only body gate. The PR body is written once at creation; no SpecGit command edits an existing PR body, and the repository's own pull-request template is never read. +- A draft pull request always fails the verdict (`pr_draft`): before + `specgit finish`, mark it ready for review — `gh pr ready <number>` + on GitHub, `glab mr update <number> --ready` on GitLab. - Finish with `specgit finish`: the verdict, derived from real git, PR, and CI evidence. Exit code 0 is the only "done". +### Issue tags + +- Every bootstrap applies the title's `kind::<type>` member + automatically; pass `--tags <a,b>` to choose the full set explicitly. +- Selection is pool-first: existing on-spec labels win verbatim; anything + missing is seeded from the built-in `kind::` catalog or the policy's + `tags:` declarations. Unknown vocabulary exits 2 naming the universe. +- Choose with restraint: at most one label per axis, none when unsure — + off-spec pool labels are reported (`tag_pool_dirty` warnings are for + humans) and never renamed by SpecGit. + ### Repair and diagnostics - `specgit pr` repairs the pull-request binding: with no arguments it @@ -333,6 +351,34 @@ verified on its own evidence, split it before binding. - Never weaken `spec_git/policy.yaml` to make a verdict pass. - `--json` is the only parse surface: stdout is exactly one JSON document; never scrape human-readable output. + +### Agent contract essentials + +- **SpecGit is the default way of working here.** Any non-trivial + task — a feature, a fix, a refactor, a docs change — is a delivery: + work items live in this tracker as issues, never in private task + lists or conversational checklists. The trigger is the decision to + start: the moment the conversation settles and you begin turning + the plan into changes, the FIRST action is + `specgit issue <type>: <title>...` — before any file edit. + Working without a binding is a contract violation, not a style + choice. Immediately after bootstrap, fill each issue body + (Why / Scope / Approach / Acceptance) from the discussion with + `gh issue edit`, then implement. Mid-conversation inventories + ("let me list everything to do") become issues, not chat + artifacts. Trivial replies and read-only questions need none of + this. +- The one rule: a delivery is done if and only if `specgit finish` + exits `0`. Never declare completion from task lists, file states, or + test runs you performed yourself. +- Branch on exit codes, not phrasing: `1` = evidence complete, fix what + the gates named; `3` = evidence missing, fix the environment first + (`specgit doctor`). Never present exit `3` as success. +- Keep the `Closes #n` references in the PR body intact; after changing + the PR body, head branch, or CI, re-run `specgit finish`. Never + bypass or reconfig a required check to make acceptance pass. +- Forge evidence flows through the user's authenticated CLI session only + (`gh` / `glab`): never read, log, or pass around tokens. <!-- specgit:block:end --> ## Tool-call discipline (hard rules) diff --git a/CLAUDE.md b/CLAUDE.md index 93e9a7e68..86505927f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,13 +190,30 @@ already exists); keep manual guidance outside them. deterministic scaffold (the `Closes #n` line for every bound issue, then Why / What changed / Evidence / Checklist sections), and writes `.specgit.yaml`. Re-running resumes; it is idempotent. -- Fill in the scaffold sections as you deliver. Its placeholders are - advisory — the closing references are the only body gate. The PR body +- Issue bodies are filled at bootstrap, from the conversation: right after + `specgit issue` succeeds, edit each issue it created (`gh issue edit <n>`) + with the discussed Why / Scope / Approach / Acceptance, then implement. + The PR scaffold's placeholders are advisory — fill those sections in as + you deliver; the closing references are the only body gate. The PR body is written once at creation; no SpecGit command edits an existing PR body, and the repository's own pull-request template is never read. +- A draft pull request always fails the verdict (`pr_draft`): before + `specgit finish`, mark it ready for review — `gh pr ready <number>` + on GitHub, `glab mr update <number> --ready` on GitLab. - Finish with `specgit finish`: the verdict, derived from real git, PR, and CI evidence. Exit code 0 is the only "done". +### Issue tags + +- Every bootstrap applies the title's `kind::<type>` member + automatically; pass `--tags <a,b>` to choose the full set explicitly. +- Selection is pool-first: existing on-spec labels win verbatim; anything + missing is seeded from the built-in `kind::` catalog or the policy's + `tags:` declarations. Unknown vocabulary exits 2 naming the universe. +- Choose with restraint: at most one label per axis, none when unsure — + off-spec pool labels are reported (`tag_pool_dirty` warnings are for + humans) and never renamed by SpecGit. + ### Repair and diagnostics - `specgit pr` repairs the pull-request binding: with no arguments it @@ -240,4 +257,32 @@ verified on its own evidence, split it before binding. - Never weaken `spec_git/policy.yaml` to make a verdict pass. - `--json` is the only parse surface: stdout is exactly one JSON document; never scrape human-readable output. + +### Agent contract essentials + +- **SpecGit is the default way of working here.** Any non-trivial + task — a feature, a fix, a refactor, a docs change — is a delivery: + work items live in this tracker as issues, never in private task + lists or conversational checklists. The trigger is the decision to + start: the moment the conversation settles and you begin turning + the plan into changes, the FIRST action is + `specgit issue <type>: <title>...` — before any file edit. + Working without a binding is a contract violation, not a style + choice. Immediately after bootstrap, fill each issue body + (Why / Scope / Approach / Acceptance) from the discussion with + `gh issue edit`, then implement. Mid-conversation inventories + ("let me list everything to do") become issues, not chat + artifacts. Trivial replies and read-only questions need none of + this. +- The one rule: a delivery is done if and only if `specgit finish` + exits `0`. Never declare completion from task lists, file states, or + test runs you performed yourself. +- Branch on exit codes, not phrasing: `1` = evidence complete, fix what + the gates named; `3` = evidence missing, fix the environment first + (`specgit doctor`). Never present exit `3` as success. +- Keep the `Closes #n` references in the PR body intact; after changing + the PR body, head branch, or CI, re-run `specgit finish`. Never + bypass or reconfig a required check to make acceptance pass. +- Forge evidence flows through the user's authenticated CLI session only + (`gh` / `glab`): never read, log, or pass around tokens. <!-- specgit:block:end --> diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 59fce4afc..36a6440a0 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -64,6 +64,29 @@ export function isDefaultTitle(title: string) { type SessionRow = typeof SessionTable.$inferSelect +export const MAX_SUMMARY_DIFF_BYTES = 256 * 1024 + +// Byte accounting mirrors the JSON serialization: 2 bytes for the "[]" wrapper, +// +1 per comma separator, so kept output never exceeds MAX_SUMMARY_DIFF_BYTES. +export function truncateSummaryDiffs(diffs: Snapshot.FileDiff[] | undefined) { + if (!diffs) return undefined + let total = 2 + const kept: Snapshot.FileDiff[] = [] + for (const item of diffs) { + const size = Buffer.byteLength(JSON.stringify(item)) + (kept.length > 0 ? 1 : 0) + if (total + size > MAX_SUMMARY_DIFF_BYTES) break + total += size + kept.push(item) + } + return kept +} + +function stripOversizedDiffs<T>(diffs: T[] | null | undefined) { + if (!diffs) return undefined + if (Buffer.byteLength(JSON.stringify(diffs)) > MAX_SUMMARY_DIFF_BYTES) return undefined + return diffs +} + export function fromRow(row: SessionRow): Info { const summary = row.summary_additions !== null || row.summary_deletions !== null || row.summary_files !== null @@ -71,7 +94,7 @@ export function fromRow(row: SessionRow): Info { additions: row.summary_additions ?? 0, deletions: row.summary_deletions ?? 0, files: row.summary_files ?? 0, - diffs: row.summary_diffs ?? undefined, + diffs: stripOversizedDiffs(row.summary_diffs), } : undefined const share = row.share_url ? { url: row.share_url } : undefined @@ -142,7 +165,7 @@ export function toRow(info: Info) { summary_additions: info.summary?.additions, summary_deletions: info.summary?.deletions, summary_files: info.summary?.files, - summary_diffs: info.summary?.diffs, + summary_diffs: truncateSummaryDiffs(info.summary?.diffs), metadata: info.metadata, cost: info.cost ?? 0, tokens_input: (info.tokens ?? EmptyTokens).input, diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 3a5ddc3ce..13a72b19f 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -3,7 +3,7 @@ import { Effect, Layer, Context, Schema } from "effect" import { SessionV1 } from "@opencode-ai/core/v1/session" import { EventV2Bridge } from "@/event-v2-bridge" import { Snapshot } from "@/snapshot" -import { Session } from "./session" +import { Session, truncateSummaryDiffs } from "./session" import { SessionID, MessageID } from "./schema" import { Config } from "@/config/config" @@ -122,7 +122,7 @@ export const layer = Layer.effect( const target = messages.find((m) => m.info.id === input.messageID) if (!target || target.info.role !== "user") return const msgDiffs = yield* computeDiff({ messages }) - target.info.summary = { ...target.info.summary, diffs: msgDiffs } + target.info.summary = { ...target.info.summary, diffs: truncateSummaryDiffs(msgDiffs) ?? [] } yield* sessions.updateMessage(target.info) }) diff --git a/packages/opencode/test/session/summary-diff-guard.test.ts b/packages/opencode/test/session/summary-diff-guard.test.ts new file mode 100644 index 000000000..7d109b40c --- /dev/null +++ b/packages/opencode/test/session/summary-diff-guard.test.ts @@ -0,0 +1,238 @@ +import { describe, expect, test } from "bun:test" +import { Database } from "@opencode-ai/core/database/database" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { SessionProjector } from "@opencode-ai/core/session/projector" +import { SessionTable } from "@opencode-ai/core/session/sql" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { Effect, Layer } from "effect" +import { eq } from "drizzle-orm" +import { Snapshot } from "@/snapshot" +import { Session as SessionNs, truncateSummaryDiffs, MAX_SUMMARY_DIFF_BYTES } from "@/session/session" +import { SessionSummary } from "@/session/summary" +import { MessageID, PartID, type SessionID } from "@/session/schema" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { pollWithTimeout, testEffect } from "../lib/effect" + +const stub = { diffs: [] as Snapshot.FileDiff[] } + +const snapshotStub = Layer.mock(Snapshot.Service, { + diffFull: () => Effect.succeed(stub.diffs), +}) + +const root = LayerNode.group([ + SessionNs.node, + SessionProjector.node, + SessionSummary.node, + Database.node, + CrossSpawnSpawner.node, +]) + +const it = testEffect( + LayerNode.buildLayer(root, { + replacements: [ + LayerNode.replace(Snapshot.node, snapshotStub), + LayerNode.replace(RuntimeFlags.node, RuntimeFlags.layer({ experimentalWorkspaces: false })), + ], + }), +) + +const giantDiffs = (count: number) => + Array.from({ length: count }, (_, i) => ({ + file: `f${String(i).padStart(3, "0")}.txt`, + patch: "x".repeat(2048), + additions: 10, + deletions: 2, + status: "modified" as const, + })) + +const setSummaryRow = (sessionID: SessionID, summary: { additions: number; deletions: number; files: number; diffs: Snapshot.FileDiff[] }) => + Effect.gen(function* () { + const database = yield* Database.Service + yield* database.db + .update(SessionTable) + .set({ + summary_additions: summary.additions, + summary_deletions: summary.deletions, + summary_files: summary.files, + summary_diffs: summary.diffs, + }) + .where(eq(SessionTable.id, sessionID)) + .run() + .pipe(Effect.orDie) + }) + +const seedUserTurn = Effect.fnUntraced(function* (sessionID: SessionID) { + const sessions = yield* SessionNs.Service + const userMessageID = MessageID.ascending() + yield* sessions.updateMessage({ + id: userMessageID, + sessionID, + role: "user", + time: { created: Date.now() }, + agent: "user", + model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("test") }, + } satisfies SessionV1.User) + const assistantMessageID = MessageID.ascending() + yield* sessions.updateMessage({ + id: assistantMessageID, + sessionID, + role: "assistant", + parentID: userMessageID, + time: { created: Date.now() }, + agent: "build", + modelID: ModelV2.ID.make("test"), + providerID: ProviderV2.ID.make("test"), + mode: "build", + path: { cwd: sessionID, root: sessionID }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } satisfies SessionV1.Assistant) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: assistantMessageID, + sessionID, + type: "step-start", + snapshot: "from", + }) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: assistantMessageID, + sessionID, + type: "step-finish", + reason: "stop", + snapshot: "to", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + return userMessageID +}) + +describe("summary.diffs source truncation", () => { + it.instance( + "summarize truncates oversized diffs to the byte budget and keeps the leading files", + () => + Effect.gen(function* () { + const sessions = yield* SessionNs.Service + const summary = yield* SessionSummary.Service + const session = yield* sessions.create({ title: "giant-summary" }) + const userMessageID = yield* seedUserTurn(session.id) + + stub.diffs = giantDiffs(300) + yield* summary.summarize({ sessionID: session.id, messageID: userMessageID }) + + const diffs = yield* pollWithTimeout( + Effect.gen(function* () { + const list = yield* summary.diff({ sessionID: session.id, messageID: userMessageID }) + return list.length > 0 ? list : undefined + }), + "summarized diffs never persisted", + ) + + expect(diffs.length).toBeLessThan(300) + expect(Buffer.byteLength(JSON.stringify(diffs))).toBeLessThanOrEqual(SessionNs.MAX_SUMMARY_DIFF_BYTES) + expect(diffs[0]?.file).toBe("f000.txt") + expect(diffs.at(-1)?.file).toBe(`f${String(diffs.length - 1).padStart(3, "0")}.txt`) + }), + { timeout: 30000 }, + ) +}) + +describe("summary_diffs read guard", () => { + it.instance("strips oversized legacy summary_diffs on read and keeps stats", () => + Effect.gen(function* () { + const sessions = yield* SessionNs.Service + const database = yield* Database.Service + const session = yield* sessions.create({ title: "legacy-giant-diffs" }) + + yield* database.db + .update(SessionTable) + .set({ + summary_additions: 12, + summary_deletions: 34, + summary_files: 56, + summary_diffs: giantDiffs(300), + }) + .where(eq(SessionTable.id, session.id)) + .run() + .pipe(Effect.orDie) + + const info = yield* sessions.get(session.id) + expect(info.summary?.additions).toBe(12) + expect(info.summary?.deletions).toBe(34) + expect(info.summary?.files).toBe(56) + expect(info.summary?.diffs).toBeUndefined() + }), + ) +}) + +describe("truncateSummaryDiffs boundaries", () => { + const item = { + file: "a.txt", + patch: "x".repeat(1024), + additions: 1, + deletions: 1, + status: "modified" as const, + } + + test("keeps an empty array as empty", () => { + expect(truncateSummaryDiffs([])).toEqual([]) + expect(truncateSummaryDiffs(undefined)).toBeUndefined() + }) + + test("returns an empty array when a single entry exceeds the budget, without throwing", () => { + const huge = [{ ...item, patch: "x".repeat(MAX_SUMMARY_DIFF_BYTES) }] + expect(truncateSummaryDiffs(huge)).toEqual([]) + }) + + test("fills the budget exactly to the largest complete entry count", () => { + const size = Buffer.byteLength(JSON.stringify(item)) + const count = Math.floor((MAX_SUMMARY_DIFF_BYTES - 1) / (size + 1)) + const kept = truncateSummaryDiffs(Array.from({ length: count + 5 }, () => item)) + expect(kept?.length).toBe(count) + expect(Buffer.byteLength(JSON.stringify(kept))).toBeLessThanOrEqual(MAX_SUMMARY_DIFF_BYTES) + }) +}) + +describe("summary diffs budget boundary", () => { + it.instance("keeps diffs at just under the budget on write and read", () => + Effect.gen(function* () { + const sessions = yield* SessionNs.Service + const session = yield* sessions.create({ title: "under-budget" }) + const info = yield* sessions.get(session.id) + + const diffs = giantDiffs(100) + expect(Buffer.byteLength(JSON.stringify(diffs))).toBeLessThan(SessionNs.MAX_SUMMARY_DIFF_BYTES) + const row = SessionNs.toRow({ ...info, summary: { additions: 5, deletions: 6, files: 100, diffs } }) + expect(row.summary_diffs).toEqual(diffs) + + yield* setSummaryRow(session.id, { additions: 5, deletions: 6, files: 100, diffs }) + const back = yield* sessions.get(session.id) + expect(back.summary?.diffs).toEqual(diffs) + expect(back.summary?.additions).toBe(5) + expect(back.summary?.deletions).toBe(6) + expect(back.summary?.files).toBe(100) + }), + ) + + it.instance("truncates oversized diffs on write within the budget", () => + Effect.gen(function* () { + const sessions = yield* SessionNs.Service + const session = yield* sessions.create({ title: "over-budget-write" }) + const info = yield* sessions.get(session.id) + + const row = SessionNs.toRow({ + ...info, + summary: { additions: 5, deletions: 6, files: 300, diffs: giantDiffs(300) }, + }) + const kept = row.summary_diffs + expect(kept?.length).toBeGreaterThan(0) + expect(kept?.length).toBeLessThan(300) + expect(Buffer.byteLength(JSON.stringify(kept))).toBeLessThanOrEqual(SessionNs.MAX_SUMMARY_DIFF_BYTES) + expect(kept?.[0]?.file).toBe("f000.txt") + expect(kept?.at(-1)?.file).toBe(`f${String((kept?.length ?? 1) - 1).padStart(3, "0")}.txt`) + }), + ) +})