diff --git a/.github/releases/v1.0.34.md b/.github/releases/v1.0.34.md new file mode 100644 index 000000000..efdf56332 --- /dev/null +++ b/.github/releases/v1.0.34.md @@ -0,0 +1,51 @@ +## opencode {VERSION} + +Stable release from `main` branch. The MCP stack moves to SDK v2 with per-server protocol era negotiation (`auto`/`legacy`/`modern`), the negotiated era becomes visible in `mcp list` and the HTTP API, dual-era interop is locked in by real-server regression tests, and local macOS builds sign their darwin binaries so `bun run build` self-validates again. + +--- + +### ๐ŸŽฏ Features + +- **MCP SDK v2 + 2026-07-28 protocol era negotiation, #447/#448 (PR #449)**: the client migrates from `@modelcontextprotocol/sdk@1.29.0` to `@modelcontextprotocol/client@2.0.0`, adding `protocol: auto | legacy | modern` per MCP server in config. `auto` probes `server/discover` with conservative fallback to the 2025 initialize handshake; `legacy` skips the probe; `modern` pins 2026-07-28 with no fallback. Known boundaries: SSE-only remote servers are no longer connectable, and transparent 404 session recovery is gone per spec. +- **Negotiated era visible in status, #450 (PR #451)**: connected MCP statuses now carry optional `era` ("modern"/"legacy") and `protocolVersion` fields, populated from the v2 client's negotiated version (omitted when unreported). `opencode mcp list` renders `connected: modern (2026-07-28)`, the HTTP API schema composes the same fields, and the JS SDK is regenerated to match. + +--- + +### ๐Ÿ› Bug Fixes + +- **macOS local builds died in their own smoke test, #454 (PR #455)**: freshly compiled unsigned darwin binaries were killed by the kernel (SIGKILL 137) before `--version` could run, failing `bun run build`; darwin targets are now ad-hoc signed (`codesign --force --sign -`) before the smoke step, triple-gated on host platform, target platform, and codesign availability. Linux hosts and cross-builds are unchanged. + +--- + +### ๐Ÿ—๏ธ Architecture / Refactor + +- **dev-lineage re-sync with main (PR #456)**: the three v2 follow-up deliveries entered main directly (SpecGit bootstrap defaulted the PR base), so a chore sync PR re-established that dev contains all of main, keeping dev the integration layer for subsequent work. + +--- + +### ๐Ÿงช Test Summary + +``` +CI gates on main at each delivery merge (#451/#453/#455): +Typecheck: pass (3/3 runs) +Unit Tests (linux): pass (3/3 runs) +E2E Tests (linux): pass (3/3 runs) +E2E Tests (windows): pass (3/3 runs) +SpecGit Acceptance: pass (3/3 runs) +mcp suite: 95 pass (92 + interop 3) +lint ratchet: 4814-4825 <= 4850 +dev sync (#456): all gates pass (1 prompt-loop flake, rerun green) +``` + +--- + +### ๐Ÿ” Verification + +- Each delivery carried an accepted SpecGit verdict (`specgit finish` exit 0) on its final head after anchor-fresh CI, plus a DAG workflow double review (standards + intent) with 8/8 load-bearing claims independently verified and zero P0/P1 findings. +- New dual-era interop tests drive the real v2 client in fresh subprocesses against in-repo fixture servers: stdio auto negotiates 2026-07-28, stdio legacy pin negotiates 2025-11-25 against the same fixture, streamable HTTP auto negotiates 2026-07-28, each with a tool-call round trip. +- Live end-to-end check against dual-era fixtures: a v1 (legacy SDK) client connects via legacy fallback; a v2 binary negotiates modern and round-trips echo tools through the TUI. +- Deliberately deferred: darwin cross-builds from Linux CI remain unsigned (ad-hoc signing requires macOS codesign); SSE-only server support is intentionally dropped with v2. + +--- + +**Full changelog:** [`{previous_tag}`...`{current_tag}`](https://github.com/LeXwDeX/OpenCode-GraphAgent/compare/{previous_tag}...{current_tag}) diff --git a/.github/releases/v1.0.35.md b/.github/releases/v1.0.35.md new file mode 100644 index 000000000..f5298c483 --- /dev/null +++ b/.github/releases/v1.0.35.md @@ -0,0 +1,42 @@ +## opencode {VERSION} + +{Prerelease/Stable} release from `{branch}` branch. Giant `summary.diffs` snapshots (37.8MB measured in the wild) can no longer starve the event loop into an unkillable state: message summaries are capped at 256KB at every write path, and oversized legacy rows are stripped on read - the session that triggered the original kill -9 now reopens cleanly. + +--- + +### ๐Ÿ› Bug Fixes + +- **ESC could not cancel a session fed a giant upstream diff, #458 (PR #459)**: `SessionSummary.summarize` wrote the full `snapshot.diffFull` output into the user message's `summary.diffs` with no size limit, so one upstream-sync turn injected a 37.8MB payload; every `message.updated` then re-serialized it into the event table (single events measured at 37.87MB, the shared DB grew to 10GB) and SSE broadcast, driving the in-process TUI into an allocation storm that starved the event loop - the abort HTTP request never got scheduled and only `kill -9` recovered it. Three guards now share `MAX_SUMMARY_DIFF_BYTES = 256KB`: source truncation in `summarize`, a write guard in `toRow`, and a read guard in `fromRow` that strips oversized legacy `summary_diffs` while keeping the additions/deletions/files stats (diffs are display-only metadata; the LLM context itself was only 695KB). Byte accounting matches the JSON serialization exactly, and round-trips are idempotent. + +--- + +### โš™๏ธ CI / Engineering + +- **specgit-accept re-aligned with the main-line harness (PR #459)**: a `specgit init --force` version refresh had reverted the workflow to the template's workspace-local CLI install (`npm install --no-save`), which dies on this bun workspace's `catalog:` protocol (EUNSUPPORTEDPROTOCOL, #434 redux), and to a `yaml`-importing wait step the global install cannot resolve. Restored the verified shape: `npm install -g specgit@^0.5.0`, regex-based policy parsing, main-only trigger, and the deviation is now recorded in AGENTS.md's local-specializations list so the next re-init re-applies it. + +--- + +### ๐Ÿงช Test Summary + +``` +CI gates on main at the delivery merge (PR #459): +Typecheck: pass +Unit Tests (linux): pass +E2E Tests (linux): pass +E2E Tests (windows): pass +SpecGit Acceptance: pass +dag-core gate: 52 pass / 0 fail +new guard tests: 7/7 pass (summary-diff-guard.test.ts) +``` + +--- + +### ๐Ÿ” Verification + +- Delivery carried an accepted SpecGit verdict (`specgit finish` exit 0) on the final head after anchor-fresh CI, via a DAG workflow (map -> coding -> verify -> review -> synthesize) with a PASS review verdict (0 blocking; 3 INFO suggestions all absorbed into the final commit: shared helper, byte-accounting comment, pinned boundary tests). +- `test/session/summary-diff-guard.test.ts` runs the real service graph (`Session.node` + real `Database.node`, only `Snapshot.diffFull` stubbed): source truncation keeps the leading files within budget, the read guard strips legacy oversized rows while preserving stats, in-budget round-trips are lossless, and empty/single-oversized/exact-capacity boundaries are pinned. +- Deliberately deferred (tracked in #458): abort-path resilience against a starved event loop, and an event-loop lag watchdog. + +--- + +**Full changelog:** [`{previous_tag}`...`{current_tag}`](https://github.com/LeXwDeX/OpenCode-GraphAgent/compare/{previous_tag}...{current_tag}) diff --git a/.gitignore b/.gitignore index 2982503fa..f2caa4f7c 100644 --- a/.gitignore +++ b/.gitignore @@ -59,6 +59,7 @@ tsconfig.tsbuildinfo .opencode/dag-init.json .opencode/workflow-drafts/ .opencode/workflow-reports/ - -# SpecGit delivery binding: local-only, never tracked -.specgit.yaml +# >>> 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/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`) + }), + ) +})