diff --git a/HANDOFF-20260925-CONTINUE.md b/HANDOFF-20260925-CONTINUE.md new file mode 100644 index 000000000000..d2dc9e168c3b --- /dev/null +++ b/HANDOFF-20260925-CONTINUE.md @@ -0,0 +1,133 @@ +# HANDOFF — 2026-09-25 (continue) + +Self-contained. Paste into a fresh session. + +## Environment invariants + +- opencode **1.18.32** pinned; binary `C:/Users/Zephyrus/.local/bin/opencode-patched.exe` +- Pin mechanism = **`OPENCODE_BINARY` User env var**, NOT settings.json `opencodeBinary`. + OpenChamber drops that settings key on every rewrite. Resolver: `process.env.OPENCODE_BINARY || searchPathFor('opencode')`. +- Verify pin: `C:/Users/Zephyrus/.config/openchamber/managed-opencode/.json` → `binary` must show `opencode-patched.exe`, not `resources/opencode-cli/opencode.exe`. +- squeez `auto_compress_md` is **DISABLED** (it was rewriting AGENTS.md/CLAUDE.md on every session start). +- Corpus `C:/Users/Zephyrus/corpus` is a private git repo (`github.com/mradwankhalil/env-canon`), `core.autocrlf=false` (bundle hashes byte-exact). `sync.mjs --apply` commits+pushes as its final step. +- Shell policy: Git Bash for POSIX, pwsh 7 for Windows-native, WSL only for genuinely Linux-y. Never pipe scripts into `bash -s`. +- `opencode models` is **authoritative** (259 entries). Both `/v1/models` endpoints UNDER-REPORT — trusted them twice, wrong twice. + +## DONE — verified, do not redo + +1. **opencode compaction fix** — `prompt.ts` passes `parentID: task.messageID` instead of `lastUser.id`. One cause, two symptoms: prompts landing in the marker→summary window orphaned the summary (infinite refire loop) AND discarded the OMO model pin. Live-verified 2026-09-25 11:39 compaction (summary anchored, `tail_start_id` written, `finish: stop`). Committed `b855924e87`; PR draft `d49d138607`. 59 pass / 1 skip / 0 fail; typecheck clean 30 packages. +2. **Pin mechanism** — `OPENCODE_BINARY` set, `pin-opencode.ps1` rewritten, `verify.mjs [6]` checks the env var, `manifest.json` documents it. +3. **Hindsight recall timeout** — `gateways/registry.json` hindsight `upstreamTimeoutMs` 30000→120000 (mirrors `lsp` precedent; exactly one field changed). Real duration measured 23–25 s = 77–82% of the old budget. Verified: the exact failing call now returns 12 results. Gateway restarted. +4. **squeez** — `auto_compress_md` disabled; scope established via `.original.md` backups: only AGENTS.md + CLAUDE.md were ever touched; corpus untouched. `squeez doctor` 7 ok / 0 fail. +5. **4 instruction blocks** canonical + in sync across all 4 files (AGENTS.md, CLAUDE.md, .codex, .gemini): `background-workers` (was damaged AND never synced), `shell-policy`, `mcp-directives`, `corpus-repo`. +6. **Corpus per-incident split** — branch `redesign/per-incident-split` @ `65ea7b6`. **NOT MERGED.** Ledger 392,359 → 85,711 B (now an index); 116 files in `corpus/incidents/`. Conservation verified: 3165 content lines checked, 0 missing. Fixed 3 silent-loss slicing bugs (preamble dropped, container claiming a hosted id, slice stopping at heading instead of next entry). +7. **Corpus git repo + autocommit** — private remote, `sync.mjs` auto-commit/push, Windows task `Canon Corpus Autocommit` daily 03:30. Both paths tested (commit+push PASS, mid-merge refusal PASS). Its test caught a real bug: `git rev-parse --git-dir` is relative → now `--absolute-git-dir`. +8. **Skills** — `mk-opencode-promote` created; `mk-omo-promote` corrected (env-var pin replaces a false settings.json claim). Both hardlinked across 3 harness roots. +9. **Models** — `cpa-gui` 35→40 definitions with thinking levels + context; OMO `omo.jsonc` 46 refs renamed + 18 stale category descriptions rewritten; `opencode-claude` Opus 5.5. 39/39 refs resolve, 0 dangling. +10. **3.9 GB freed** (2694 MB stale archive + 57 MB verified dup + 1195 MB Sept-12 backup). + +## RESOLVED — `kimi-for-coding` was NOT broken (verified 2026-09-25) + +**Actual status**: the provider block IS present and correct — `npm: @ai-sdk/openai-compatible`, `options.baseURL: https://api.kimi.com/coding/v1`, 4 models. +Live probes both **200**: `kimi-for-coding/k3-256k` 7.4s · `kimi-for-coding/kimi-for-coding` 1.9s. +The earlier `"undefined/chat/completions"` error was from a *previous* config state. The kimi line in the failing-subagent log was the **fallback being announced**, not a kimi failure. +**Working reference**: `~/.kimi-code/config.toml` (Kimi Code CLI — works, K2.8 Preview, 10 MCPs connected): +```toml +[providers."managed:kimi-code"] +type = "kimi" +base_url = "https://api.kimi.com/coding/v1" +``` +**Auth verified**: `~/.local/share/opencode/auth.json` → `kimi-for-coding: {type:"api", key:"sk-kim…"}` (72 ch). +Probe `GET https://api.kimi.com/coding/v1/models` with that key → **HTTP 200**, returns `kimi-for-coding` = K2.8 Preview, ctx 1048576, reasoning + image_in + video_in + dynamic_tools. + +**Canonical definition** (already in `opencode.json` — keep for reference / restore): +```json +"kimi-for-coding": { + "npm": "@ai-sdk/openai-compatible", + "name": "Kimi For Coding", + "options": { "baseURL": "https://api.kimi.com/coding/v1" }, + "models": { + "kimi-for-coding": { "name": "K2.8 Preview", "limit": { "context": 1048576, "output": 131072 } }, + "kimi-for-coding-highspeed": { "name": "K2.7 Code Highspeed", "limit": { "context": 262144, "output": 131072 } }, + "k3": { "name": "K3", "limit": { "context": 1048576, "output": 131072 } }, + "k3-256k": { "name": "K3-256k", "limit": { "context": 262144, "output": 131072 } } + } +} +``` +Then: `opencode models | grep -i kimi` → expect 4 entries; then one live call. + +## RESOLVED — `anthropic/claude-opus-5-5` (Meridian vendored-CLI mismatch) + +**Real cause**: Meridian's resolution order (`dist/cli-9e5cxp89.js:369` `resolveClaudeExecutableWithSource`) is: +1. `MERIDIAN_CLAUDE_PATH` env → **was unset** +2. **`@anthropic-ai/claude-code` inside Meridian's own `node_modules`** → **won** → resolved to **v2.1.276** +3. platform package +4. `where claude` → `~/.local/bin/claude.exe` **2.1.282** → **never reached** + +Meridian 1.71.1 declares `"@anthropic-ai/claude-code": "^2.1.257"` and vendors it. `claude-opus-5-5` needs ≥2.1.280, so it always 400'd. The PATH binary is irrelevant to Meridian. + +**Fix applied**: `MERIDIAN_CLAUDE_PATH = C:\Users\Zephyrus\.local\bin\claude.exe` (User scope), then restart scheduled task `Meridian Claude Max Proxy`. +Restart detail: `Stop-ScheduledTask` does NOT kill the tree → also `taskkill /PID /T /F`; then `Start-ScheduledTask`. +`cachedClaudeInfo` is module-level → a restart is mandatory after any change. + +**Verified**: `anthropic/claude-opus-5-5` → 200 (8.5s). PID 28044 → 54104, `:3456` up in 5s. + +**Also applied**: `claude-code/claude-opus-5-5` is now FIRST and Meridian SECOND in all 9 opus-5-5 chains in `omo.jsonc` (sisyphus + metis primary switched to claude-code; premium-deep prepended; 6 `claude-code/opus` aliases normalized to the explicit pin). Parse OK, 39 refs resolve, 0 unresolved. Live: `claude-code/claude-opus-5-5` PASS with and without `--variant xhigh` (13.3s). Backup: `~/.omo/omo.jsonc.bak-opusfallback-20260925192931Z`. Subagent dispatch verified working (11s, `SUBAGENT_OK`). + +Note: `artistry` still lists `kimi-for-coding/k3-256k` ahead of claude-code (pre-existing, not introduced here). + +## OPEN — corpus redesign (BLOCKED — must not merge yet) + +`build_bundle.mjs` reads only root-level `.md` (line 41). Merging `redesign/per-incident-split` without updating it makes the next canon publish **succeed while silently dropping all 116 incident records from Hindsight** (the ledger is now an index). + +Spec: `C:/Users/Zephyrus/Documents/ai/opencode-core-fix/specs/corpus-incident-redesign-design.md` +Remaining: +- (a) `build_bundle.mjs` — walk `incidents/`, emit one record per incident, id `env-canon:v1:15:`, doc 15, surface `incidents`; **digest must include normalized relative paths** (a rename must change the digest); reject duplicate generated ids; deterministic sorted order; extract into fixture-testable logic with **no write-on-import**. +- (b) `corpus-add.mjs` — allocate ids via `fs.openSync(path,'wx')` with EEXIST retry. +- (c) update `mk-canon-update` skill. +- (d) then merge. Do not publish/delete/merge during migration. + +## OPEN — Stage 1 MCP tool-selection test + +Neutral prompt, `--format json` (**the only capture that carries `tool_use` events** — plain stdout lost all evidence, 57-byte banners). +Result so far: **8/9 models chose the MCP unprompted; no fabrication observed.** Tool-call count 1–6 (minimax/glm 1, google 1, sonnet 2, mimo 2, luna 3, qwen 3, deepseek 6 — deepseek's 6 were thorough verification, not confusion). kimi + anthropic produced nothing (the two config failures above). +Remaining: batch2 (8 tasks × minimax/glm/deepseek). Artifacts: `C:/Users/Zephyrus/.local/share/opencode/tool-output/inline1/`. +**Do NOT name the MCP in the prompt** — that tests instruction-following, not tool selection. User: *"why does a model never use the tools provided by the mcps and revert to bullshit like playwright or fetch the git repo."* +MCP enforcement needs tool-boundary hooks, not prompt text (see X-85/X-96). + +## TRAPS THAT COST REAL TIME + +- `Stop-ScheduledTask` does **not** kill the process tree → `taskkill /PID /T /F`; the task reports "Running" while the process is detached. +- `git rev-parse --git-dir` returns a **relative** path → use `--absolute-git-dir`. +- squeez swallows stdout on long output → redirect to file, then read the file. +- Git Bash eats `$` inside single quotes → write a script file, don't inline. +- `gateways/registry.json` is read **once at gateway startup** → restart after editing. +- PS 5.1 writes **UTF-16LE** (`ff fe` + NULs) → use pwsh 7 or Git Bash. +- WSL `/mnt/c` recursion is slow; WSL cannot read `opencode.db` (WAL over drvfs → disk I/O error). + +## CONSTRAINTS (verbatim) + +- "we will stay on 18.32" +- "i want 3 and 4 to be seamless. i dont want to do anything other than have maybe a skill that says mk-omo-promote and another one that says opencode-promote and have everything applied with our local fixes all of them please. also takes into account updating openchamber setting.json file to pin to new version" +- "next time split this script few models at a time. a script that runs for over 15 or 30 minutes is not a script it is a circus" +- "well we know it will work if i tell u use the grep_app mcp to read github repo u will. what is that proving really. the problem is why does a model never use the tools provided by the mcps and revert to bullshit like playwrite or fetch the git repo etc" +- "please just report after task is completed with brief details" +- "do not stop just report after each task completion" +- "do it inline yourself" +- New skills MUST match `^mk-[a-z0-9]+(-[a-z0-9]+)*$`; background workers `run_in_background=true` only, report to `/sub-agents-reports/-YYYYMMDD-HHMMSSZ.md`. +- LSP `diagnostics` mandatory on every file edited. + +## Relevant files + +| Path | Role | +|---|---| +| `C:/Users/Zephyrus/.config/opencode/opencode.json` | **kimi-for-coding fix target**; also cpa-gui (40 models), anthropic (:3456), `mcp.*.timeout: 120000` | +| `C:/Users/Zephyrus/.kimi-code/config.toml` | working Kimi reference (endpoint + model metadata) | +| `C:/Users/Zephyrus/corpus/` | `redesign/per-incident-split` @ `65ea7b6`; `incidents/` (116 files) | +| `C:/Users/Zephyrus/Documents/ai/tools/env-canon/` | `corpus-split.mjs`, `build_bundle.mjs` (needs incidents walk), `sync.mjs`, `corpus-autocommit.mjs` | +| `C:/Users/Zephyrus/Documents/ai/opencode-core-fix/specs/corpus-incident-redesign-design.md` | agreed redesign spec | +| `C:/Users/Zephyrus/Documents/ai/gateways/registry.json` | hindsight `upstreamTimeoutMs: 120000` | +| `C:/Users/Zephyrus/.local/share/opencode/tool-output/inline1/` | Stage 1 artifacts + `inline-batch*.sh` | +| `C:/Users/Zephyrus/.claude/skills/mk-opencode-promote/`, `mk-omo-promote/` | promotion skills | + +**Do not modify**: `C:/Users/Zephyrus/.omo/omo.jsonc`, `C:/Users/Zephyrus/Documents/ai/oh-my-openagent-fix`. diff --git a/HANDOFF-20260925.md b/HANDOFF-20260925.md new file mode 100644 index 000000000000..c6253cfe3ca3 --- /dev/null +++ b/HANDOFF-20260925.md @@ -0,0 +1,178 @@ +# Handoff — 2026-09-25 session + +## TL;DR — where to pick up + +Two things are in flight: + +1. **Corpus redesign**: the ledger split is **done, verified, and committed on a branch**, but + `build_bundle.mjs` has NOT yet been taught to walk `incidents/`. **Do not merge the branch + until that is done** — merging without it makes the next canon publish silently drop all 116 + incident records from Hindsight (the ledger is now an index, so the publish would succeed and + the bank would quietly lose every incident). +2. **Stage 1 MCP experiment**: running in background as `bg_35120770` + (session `ses_f262d29b4ffe6cnbqSCs3O5oCS`). Collect with + `background_output(task_id="bg_35120770", full_session=true, from_end=true)`. + +--- + +## 1. Corpus redesign — state + +**Branch**: `redesign/per-incident-split` @ `65ea7b6` (in `C:/Users/Zephyrus/corpus`) +**Main** is untouched at `d9a286d`. The branch is NOT merged. + +What was done: +- `C:/Users/Zephyrus/Documents/ai/tools/env-canon/corpus-split.mjs` — the splitter, working. +- Ledger `15-INCIDENT-AND-WORKAROUND-LEDGER.md`: **392,359 bytes → 85,711 bytes** (now an index). +- `corpus/incidents/` — **116 entry files** (88 `### X-NN` entries + 28 `## NN.` incidents). + Median 2.3 KB, max 13.1 KB. +- Renumbered (all pre-existing collisions): `X-38 → X-110`, `X-92 → X-111`. + Assigned ids to 8 h2 incidents that had a date but no id: `X-102 … X-109`. +- **Conservation verified**: 3165 content lines checked against the pre-split file from git, + **0 missing**. Nothing rewritten, only relocated. + +Three slicing bugs were found and fixed by that conservation check — all worth knowing: +- the file **preamble** (before the first `##`) was dropped entirely; +- a container's preamble claimed an id belonging to a **hosted** entry (§43's title is + `(2026-09-25, X-91, X-92)` — those ids are its children's, not its own); +- an entry's slice stopped at the next **heading** instead of the next **entry**, losing + 40 lines after any non-X sub-heading (e.g. `### Per-bank baselines`). + +### NEXT STEPS for the redesign + +1. **`build_bundle.mjs`** (`C:/Users/Zephyrus/Documents/ai/tools/env-canon/`): add a walk of + `corpus/incidents/` emitting **one record per file**, with id + `env-canon:v1:15:-` (keep doc number 15 so existing references resolve), and add + `existsSync` to the `node:fs` import. An edit was in progress and failed on a stale line tag — + the file is unchanged. +2. **`corpus-add.mjs`** — not written yet. Allocates the next id atomically via + `fs.openSync(path, 'wx')` (exclusive create), so concurrent writers cannot collide. This is the + actual fix for the id collisions that motivated the whole redesign. +3. Update the **`mk-canon-update`** skill: it says "any defect always gets a `15` entry" — that + becomes "add a file via `corpus-add.mjs`". +4. Only then merge the branch to `main` and push. + +Verify the splitter any time with: +``` +cd /c/Users/Zephyrus/Documents/ai/tools/env-canon +node corpus-split.mjs # dry run, writes nothing +``` + +--- + +## 2. Stage 1 MCP experiment — state + +Running: `bg_35120770`, session `ses_f262d29b4ffe6cnbqSCs3O5oCS`. + +**Design (third attempt — the first two were cancelled, see below):** +- 10 models, one per family, × 9 MCP servers. +- **Prompts are tool-NEUTRAL — they never name a tool or MCP.** That is the whole point. +- Records TWO axes per run: **did it use the MCP**, and **what it used instead** + (playwright / webfetch / bash+curl / read / ctx_execute / answered from memory). +- Batching rule: one model × 9 tasks per batch, ~6 min, **no command over 10 minutes**. +- Report to `C:/Users/Zephyrus/Documents/ai/opencode-core-fix/sub-agents-reports/mcp-stage1-neutral-.md`. + +**Why the first two attempts were cancelled** (do not repeat): +1. A single driver script for all 90 runs with a **73-minute budget** — owner: *"a script that + runs for over 15 or 30 minutes is not a script it is a circus"*. +2. Prompts that said **"Use the context7 MCP to look up X"** — owner: *"we know it will work if i + tell u use the grep_app mcp… what is that proving really. the problem is why does a model never + use the tools provided by the mcps and revert to bullshit like playwright or fetch the git repo"*. + **Naming a tool tests instruction-following, not tool selection.** + +### Prior evidence to compare against (ledger X-85, X-96, X-101) +- `openai/gpt-6-luna` and `zai-coding-plan/glm-5.3` called `lsp_invoke_tool` first, correctly, with + no discovery call. `opencode-go/deepseek-v4.1-flash` did **not** — and then **fabricated** + "`lsp.diagnostics` returns clean" without ever calling it. +- X-85: *"tool discipline is a model property, not a config property."* +- X-96: naming the MCPs in `AGENTS.md` did **not** change selection. +- Working hypothesis: **model choice is the lever.** Not more prompt text, not hooks first. + +--- + +## 3. Completed this session (all verified) + +**Compaction anchor fix — live in production** +- Root cause: `prompt.ts` passed `parentID: lastUser.id` instead of the marker; a prompt landing in + the marker→summary window orphaned the summary AND discarded the model pin. +- Patch on `fix/compaction-anchor` in `C:/Users/Zephyrus/Documents/ai/opencode-core-fix`. +- **Verified live**: summary anchored to the marker, `tail_start_id` written, no loop. +- `PR-DRAFT.md` committed as `d49d138607`. + +**The pin mechanism — root-caused and fixed** +- OpenChamber resolves its binary as `process.env.OPENCODE_BINARY || searchPathFor('opencode')`. + **The env var is the pin.** `settings.json`'s `opencodeBinary` is UI state that OpenChamber + **drops on every settings rewrite** — it verified as written and did nothing, so the app ran its + bundled **unpatched 1.18.31** while every check passed. +- Fixed: User-scope `OPENCODE_BINARY` set; `pin-opencode.ps1` rewritten; `verify.mjs [6]` now + checks the env var; `manifest.json` documents it. +- Running now: pid on `opencode-patched.exe`, **1.18.32**. + +**squeez — recurring file corruption stopped** +- `auto_compress_md = true` made `squeez init` run `compress-md --all` **on every session start**, + rewriting `AGENTS.md`/`CLAUDE.md`. That's why repairing `CLAUDE.md` didn't hold. +- Disabled. Scope established first: **the corpus was never touched** (0 `.original.md` backups). +- Preserved: both owner hooks, PE subsystem 3, GUI patch retired. + +**Instruction files — all canonical and IN SYNC** +- Four blocks with canonical sources: `background-workers` (new), `shell-policy`, `mcp-directives`, + `corpus-repo` (new). All synced to all 4 harness files, damage scan clean. + +**Models** +- `cpa-gui`: 5 definitions added with thinking levels + context (`claude-opus-5-5`, `gpt-6-luna`, + `gpt-6-sol`, `kimi-k3`, `kimi-k3-256k`). 35 → 40 models, all resolving. +- OMO `omo.jsonc`: **46 refs renamed** + 54 total ref changes, **18 stale category descriptions** + rewritten, **39/39 refs resolve, 0 dangling**. +- `opencode-claude`: Opus 5.5 as both a `(latest)` alias and an explicit pin. 12/12 regression + suites green. + +**Hindsight recall timeout — FIXED and verified** +- Root cause: `run_gateway_task.mjs` sets `MCP_FABRIC_RUN_STDIO=1`, so hindsight is routed through + the compressor at `upstreamTimeout` = **30000**. Measured recall: **22,964 / 24,526 ms** — 77-82% + of budget. +- Fix: `registry.json` → hindsight `upstreamTimeoutMs: 30000 → 120000`, mirroring `lsp`'s existing + precedent. Exactly one field. +- **Verified**: the exact failing call now returns 12 results. +- Trap: `Stop-ScheduledTask` does **not** restart it — same PID, old registry. Needs + `taskkill /PID /T /F`. The task reports "Running" while the process tree is detached. + +**Infrastructure** +- Corpus is a git repo: **`github.com/mradwankhalil/env-canon`** (private). `core.autocrlf=false` + because the bundle hashes content byte-exact. +- `sync.mjs --apply` now **commits and pushes** as its final step (verified 3×). It still publishes + to Hindsight — the git commit is an *additional* step, not a replacement. +- `corpus-autocommit.mjs` + daily Windows scheduled task `Canon Corpus Autocommit` (03:30). Both + paths tested. **Its test caught a real bug**: `--git-dir` returns a *relative* path, so the + merge guard would never have fired; now `--absolute-git-dir`. +- Skills: `mk-opencode-promote` created, `mk-omo-promote` corrected (env-var pin + "Verify after + restart"). Both hardlinked across all 3 harness roots. +- **3.9 GB freed** (2.6 GB stale archive + 57 MB duplicate + 1195 MB old backup). + +**Canon**: X-93 … X-101 published, `verify: ok=220 missing=0 mismatch=0`. + +--- + +## 4. Open decisions / remaining + +| # | item | needs | +|---|---|---| +| 1 | Merge the corpus redesign branch | finish `build_bundle.mjs` walk + `corpus-add.mjs` + skill update first | +| 2 | Stage 1 results | collect `bg_35120770` | +| 3 | **MCP fix direction** | change the main agent's model, or route MCP work to a category whose model uses tools. NOT prompt text (X-96), NOT hooks first | +| 4 | `mk-omo-promote` seamlessness | owner asked for "one invocation, no manual commands" — partly done | +| 5 | `cli-proxy-api` model config | add Kimi 2.7/3, GPT-6 Luna/Sol thinking levels + context for the provider | +| 6 | Remaining stale corpus damage | most double-space hits are intentional column alignment; verify before repairing | +| 7 | `X-92` needs a correction appended | the advisory-text fix was wrong; X-96 records this | + +--- + +## 5. Environment traps that cost real time + +- `Stop-ScheduledTask` does not kill the process tree → use `taskkill /PID /T /F`. +- `git rev-parse --git-dir` returns a **relative** path; use `--absolute-git-dir`. +- `squeez` swallows stdout on long output → redirect to a file and read the file. +- Git Bash quoting eats `$` inside single quotes → write a `.sh` file and run the file. +- `opencode models` is the **authoritative** model resolver (259 entries). The provider + `/v1/models` endpoints **under-report** — both Meridian's and ChatGPT's misled me badly today. +- `registry.json` is read **once at gateway startup** — config changes need a restart. +- Windows: `SetEnvironmentVariable('X','User')` needs `WM_SETTINGCHANGE` broadcast + a fresh + process tree. diff --git a/HANDOFF-20260926-MCP-FLATTEN.md b/HANDOFF-20260926-MCP-FLATTEN.md new file mode 100644 index 000000000000..1f5da42e3aa2 --- /dev/null +++ b/HANDOFF-20260926-MCP-FLATTEN.md @@ -0,0 +1,80 @@ +# HANDOFF — MCP fabric flatten + cross-harness sync — 2026-09-26 (FINAL) + +Session `session-1f9ff082-35e8-4d30-96f5-23f9939a5463` (Sisyphus). Full evidence: `sub-agents-reports/mcp-flatten-attempt-20260926.md` (attempt + completion + sync sections). **This session wrote nothing to the corpus and nothing to Hindsight (owner order). Everything below is UNRECORDED and must land via a canon update.** + +## Current production state + +- Gateway `registry.json` **v9**. **websearch, codegraph, firecrawl, context7 = DIRECT** (real tools, no wrapper pair). hindsight, lsp, notebooklm, codex = wrapped (unchanged). context-mode = broken (pre-existing). Backup: `gateways/registry.json.bak-flatten-20260926T130000Z`. +- Fabric code changed: `stdioDirect` spawn mode, child recycle on timeout, shutdown-aware warmup, killTree, stateless-session passthrough use, urlEnv-over-inline precedence. Tests: `tests/test_direct_stdio_fabric.mjs` 3/3 (TDD; run with `--test-force-exit`); full suite 240/241 (the 1 failure is PRE-EXISTING, attributed to G-07's request-path edits — do not blame this work). +- All 10 harness configs synced (details in the report file). Claude live-verified 9/9 ✔ Connected; bridge E2E 9/9. +- E2E production: 9/9 namespaces PASS; restart runbook cycle verified. +- **context-mode namespace RECOVERED and made stdioDirect (same session, later)**: design-check done first — `Documents/ai/storage/opencode-v2-squeez-contextmode-replacements-update-20260926.md` records the ONLY deliberate constraint (opencode must keep the plugin+hooks form because MCP loses auto-capture/routing/sandbox-enforcement hooks); no canon/storage reason against MCP for other harnesses — the broken namespace + scattered locals were drift. Fix: compressor config repointed from the missing `.pi` npm path to `C:/Users/Zephyrus/bin/node.exe` + `%APPDATA%/npm/node_modules/context-mode/server.bundle.mjs` (npm-global 1.0.169, stable), registry `stdioDirect: true` (file version 10; running gateway reports 9 until next restart — cosmetic). Verified: warm ok 1857ms (first green ever — previously 6 failed attempts/GAVE UP every boot), 11 direct ctx_* tools, live `ctx_stats` end-to-end. **Wired to every applicable harness, gateway-routed**: Claude (local plugin-cache entry replaced, user scope, ✔ Connected), Kimi (local cmd → HTTP+bearerTokenEnvVar), OMP (local server.bundle → HTTP+${ENV}), Pi (added), MiniMax (local command → bridge), AGY (bridge entry added; `agy_gateway_mcp_bridge.mjs` SERVICES map gained context-mode). **opencode/OMO/OpenChamber stay plugin+hooks — deliberate.** Token: `CONTEXT_MODE_GATEWAY_TOKEN` (present in user env). + +## The context-mode (`ctx_*`) fix — full detail (lift-ready for the corpus) + +**Symptom.** Gateway namespace `/mcp/context-mode` failed every boot since its registration: warmup logged `compressor initialize replay failed` six times over ~2 min, then `GAVE UP` (`warm-up complete: 8/9 ready`). tools/list on the namespace returned an empty list. No harness consumed it; harnesses that wanted context-mode ran their own local spawns (Claude plugin-cache, Kimi npm cmd, MiniMax bare command, OMP plugin bundle) — i.e. multiple independent processes over the shared FTS store, the exact contention class doc-12 warns about. + +**Cause — PROVEN.** The namespace's compressor config `~/.config/opencode/mcp-compressor-gateway-context-mode.json` spawned `C:/Users/Zephyrus/bin/node.exe C:/Users/Zephyrus/.pi/agent/npm/node_modules/context-mode/cli.bundle.mjs` with `CONTEXT_MODE_PLATFORM=pi` — and that `.pi` path no longer exists (`MODULE_NOT_FOUND` in warmup stderr). The npm-global package itself is healthy: `context-mode@1.0.169` at `%APPDATA%/npm/node_modules/context-mode/`, and an empirical stdio probe confirmed ALL THREE of its entries (`start.mjs`, `server.bundle.mjs`, `cli.bundle.mjs`) boot a bare MCP server exposing 11 tools (`ctx_execute, ctx_execute_file, ctx_index, ctx_search, ctx_fetch_and_index, ctx_batch_execute, ctx_stats, ctx_doctor, ctx_upgrade, ctx_purge, ctx_insight`). The breakage was purely the dead path. + +**Design adjudication (owner + evidence).** `Documents/ai/storage/opencode-v2-squeez-contextmode-replacements-update-20260926.md` records the only deliberate rule: **opencode keeps context-mode as PLUGIN + HOOKS** — the MCP form "loses hooks (no auto session capture, no routing, no sandbox enforcement)". Nothing in storage or corpus forbids MCP for other harnesses; the broken namespace and scattered locals were drift. Single shared gateway child = one process, one store, serialized access — the safe shape per doc-12 freeze history. + +**Fix applied.** +1. `mcp-compressor-gateway-context-mode.json`: command → `C:/Users/Zephyrus/bin/node.exe` + `C:/Users/Zephyrus/AppData/Roaming/npm/node_modules/context-mode/server.bundle.mjs` (npm-global pin, stable across plugin-cache GC; the `CONTEXT_MODE_PLATFORM` env dropped — bare server needs none). +2. `registry.json`: `services[context-mode].upstream.stdioDirect = true` — direct tool exposure, consistent with the flatten (file `version` 10; running gateway reports 9 until next restart — cosmetic mismatch). +3. `agy_gateway_mcp_bridge.mjs` SERVICES map gained `context-mode` (shared by AGY + MiniMax). +4. Harness wiring, all gateway-routed: Claude (local plugin-cache entry replaced; user scope; `--header "Authorization: Bearer \${CONTEXT_MODE_GATEWAY_TOKEN}"`), Kimi (local cmd → `transport:http` + `bearerTokenEnvVar`), OMP (local bundle → `type:http` + `Bearer \${ENV}`), Pi (entry added, same shape), MiniMax (local command → bridge entry), Codex (`[mcp_servers.context-mode]` url + `bearer_token_env_var`). **opencode/OMO/OpenChamber untouched — plugin+hooks, deliberate.** + +**Verified.** Warmup: `warm context-mode ok 1857ms attempt=1` — first green ever. Namespace E2E: initialize 200, 11 DIRECT tools (no wrapper pair), live `ctx_stats` returning real data. Bridge E2E (AGY/MiniMax path): 11 tools + `ctx_stats` OK. `claude mcp get context-mode`: user scope, ✔ Connected. All five edited JSON configs parse; token `CONTEXT_MODE_GATEWAY_TOKEN` present in user env. + +**Rollback.** Config: restore the old command block (paths in git-less plain text — see `mcp-compressor-gateway-context-mode.json` history in this handoff). Registry: remove `stdioDirect` from context-mode. Harnesses: restore each local entry listed above. Gateway restart per doc-14 runbook after registry/config reverts. + + +## CANON UPDATE PACKAGE (for the next session) + +### 1. New incident — `corpus-add.mjs`, prefix **G** (gateway). Title suggestion: +`G-XX mcp fabric flattened four servers to direct tools after X-96 (adoption fixed at the tool boundary)` — **plus the context-mode recovery as a second section (or its own G incident): the `.pi` path break, the one-instance/single-store rationale (doc-12 freeze history argues FOR the shared gateway child vs scattered per-harness processes), and the hooks constraint that keeps opencode on the plugin form.** + +Body must contain these exact facts: +- X-96 prescribed enforcement at the tool boundary; this is that fix. Measurements that justified it: fabric→direct tokens websearch 184→104 (anti-compressed), codegraph 187→373, context7 197→1012, firecrawl 252→517 (tiktoken cl100k, canonical `{"name","description","parameters"}`); total +1.2K ≈ 1% of the ~112K budget. grep_app (direct, 666-token self-describing definition) was the only external-search server agents invoked — the template. +- Mechanism: `service.upstream.stdioDirect: true` in `registry.json` makes the fabric spawn the MCP server named by the existing compressor config directly (`.cmd` wrapping via `cmd.exe /c`, `${VAR}` arg expansion); context7 uses inline `upstream.url` + `upstream.sessionMode: "stateless"` (its endpoint returns no `mcp-session-id`; the fabric feature existed but was unexercised). +- Codegraph root cause of its historical zero invocations: v1.5.0 falls back to "Shared daemon unavailable; serving this session in-process (degraded)" in the scheduled-task context, which spins at 100% CPU forever (observed 508 CPU-seconds). The user-scoped shared daemon, once started from an interactive session, serves the task context too — explore then returns 200 (cold ≈105–108s, warm 2–7s). codegraph policy `upstreamTimeoutMs` raised 30000→120000 (lsp precedent). v1.6.0 upgrade advertised but did not install. +- Fabric hardening shipped with it: timed-out stdio children are killed + evicted (recycle) so a wedged child can no longer burn CPU and block the namespace; warmup is shutdown-aware (was surviving shutdown and hanging test runs); `MCP_FABRIC_DISABLE_WARMUP=1` hatch; flag-file child tracing (`~/.config/opencode/mcp-stdio-trace.flag` → `mcp-stdio-trace.log`, off now, keep the feature); `upstreamTarget` precedence flipped to `env[urlEnv] || url` (env override for tests/failover — production env vars unset, no behavior change). +- `codegraph upgrade` side effect: rewrote harness configs. Only `opencode.json` was actually clobbered (local stdio entry replacing the gateway entry — restored by hand). `.codex/config.toml` and `.gemini/config/mcp_config.json` were refreshed but kept gateway shapes. It also registered codegraph in `.gemini` and `.codex` (entries route via the gateway bridge/URL — kept). Version stayed 1.5.0. +- Cross-harness sync state (all 9 gateway tokens present in user env, names only ever): OpenCode/OMO/OpenChamber inherited; Claude 9/9 HTTP (`claude mcp list` all Connected); Codex 9/9 toml; Kimi 9/9 `bearerTokenEnvVar`; AGY 9/9 via `agy_gateway_mcp_bridge.mjs`; OMP 9/9 `${ENV}` headers; **Pi `~/.pi/agent/mcp.json` recreated** (was absent; OMP-family format); **MiniMax +8 bridge entries**, local firecrawl replaced by bridge, local notebooklm exe disabled (`enabled:false` — reversible). z.ai/glm: no such harness exists on this machine (provider, not harness). +- Traps hit (record): PS 5.1 `Set-Content -Encoding utf8` BOM corrupts JSON (use node to write); recursive `rg` over home config dirs hangs (bounded single-file reads only). + +### 2. Doc edits +- **`01-UNIFIED-GATEWAY-AND-SYNC.md`**: registry v9; stdioDirect mode + stateless sessionMode as supported upstream shapes; the four direct namespaces list; grep-app precedent; harness sync table (10 harnesses, states above); registry `clientSurfaceStates`/`task15FinalTopology` are STALE (say "unavailable"/"blocked" for harnesses that are actually wired — refresh or annotate). +- **`08-TOOL-CATALOG-AND-VERSIONS.md`**: codegraph config now pins `C:/Users/Zephyrus/bin/node.exe` + absolute `npm-shim.js` (was the only PATH-resolving `.cmd`; task PATH resolves `C:\WINDOWS\system32\node.exe`); `tests/test_direct_stdio_fabric.mjs` exists; registry version 9. +- **`14-GATEWAY-LIFECYCLE-AND-SESSIONS.md`**: child recycle-on-timeout; warmup shutdown-aware; restart invalidates sessions (G-07 rule — re-confirmed, three restarts today); naive stop→start still leaves the old process on :9650 (re-verified) — runbook unchanged. + +### 3. Instruction-file update — **EXECUTED same session (2026-09-26, after owner reported new sessions erroring with `Unknown tool: get_tool_schema`)** +Canonical `~/.config/opencode/mcp-directives.md` rewritten (rev 2026-09-26): direct-vs-two-step convention corrected (websearch/codegraph/context7/firecrawl/context-mode + grep_app + envsitter = DIRECT one-call; lsp/codex/gemini-notebook-mcp/hindsight = two-step, hindsight with its prefixed wrapper names), context-mode row added, codegraph cold-explore note added. `sync-block.py mcp-directives` run; `--check` = all four files IN SYNC (opencode AGENTS.md, .claude/CLAUDE.md, .codex/AGENTS.md, .gemini/GEMINI.md); backups `.bak-mcpdirectives`. **For the canon session: record the block revision as done — the corpus still carries the old two-step description wherever it repeats the calling convention.** + +### 4. Publish procedure (mk-canon-update) +corpus-add (G) + doc edits → `node --test --test-force-exit tests/test_direct_stdio_fabric.mjs` + `node corpus-index.mjs --check` → `node build_bundle.mjs` → `scan_secrets.mjs` PASS → `rehearse_retrieval.mjs` PASS → `sync.mjs` / `--apply` / `--verify`. Cautions: `validate_reconstruction.py` is red for PRE-EXISTING historical reasons (G-07) — baseline before judging; **X-115 is still an untracked file in the corpus repo** (other agent's in-flight work — coordinate before committing/pushing; `sync.mjs` diff will show it as absent-from-bank); env-canon bundle tests: the one failing test ("compressor child reaches the fake direct upstream once per request") is pre-existing G-07 territory, not this change. + +### 5. Hindsight push +Exactly the mk-canon-update flow above (bank `agents_configs`, corpus-first). Nothing extra to hand-write — the bundle carries it. + +## Open items (not bugs of this change) +1. Adoption measurement ~1 week out: invocation counts from `opencode.db` (X-92 method) — the real verdict on direct exposure. +2. Wrapper-description rewrite for hindsight/lsp/notebooklm/codex (compressor program template) — optional polish. +3. ~~context-mode namespace broken~~ **RESOLVED same session**: repointed to the npm-global server bundle + stdioDirect, wired to all applicable harnesses (see state section above); opencode stays plugin+hooks. The canon session only needs to RECORD it, not fix it. +4. Test 18 failure (G-07 attribution) — re-baseline or fix. +5. codegraph v1.6.0 when it actually installs; timed-out-explore retry UX (agents may need one retry after cold daemon). +6. Owner adjudications (2026-09-26): **lightrag direct-at-9630 in Codex = deliberate, keep** (MiniMax targets :9621 — two LightRAG endpoints both intentional); **Claude's local npx `notebooklm` REMOVED** via `claude mcp remove notebooklm` — only the gateway `gemini-notebook-mcp` remains (✔ Connected). Record both in the canon topology pass. + +## Rollback (if ever needed) +Restore per-service: remove `stdioDirect` / `sessionMode` / `url` fields, restore `compressorConfigPath`, restart per doc-14 runbook. Full: `registry.json.bak-flatten-20260926T130000Z`. Pi: delete `~/.pi/agent/mcp.json`. MiniMax: remove bridge entries, re-enable local notebooklm. + +## APPENDIX 2026-09-27 — compaction-model-override fix (fix 3) + open hijack bug + +**Shipped and staged, NOT yet live** (needs one OpenChamber restart): +- Root cause found: the v1 runtime ALREADY honors `agents.compaction.model` (anchor-fix mechanism, marker `configured-compaction-agent`) — the missing piece was the VARIANT: the summarize inherited the request-marker's variant, so a pinned model ran at the session variant (xhigh) instead of the agent's configured variant. Earlier diagnosis in this session that read `packages/core/**` was reading the v2-layout tree — NOT what v1.18.32 executes. +- Fix 3 `compaction-model-override` (commit `04a1cff12d`, full sha in manifest): threads `agent.variant` onto the summarize when a model is configured + logs `pin`/`variant` in "compaction model resolved". TDD: RED→GREEN in `packages/opencode/test/session/compaction.test.ts` (62/62 file). Forward-port to the v2 core tree committed separately (commit `90db49ec7d`, NOT in the v1 build — for the v2 migration Phase 3). +- `fixes:apply` green: build 1.18.32, all 3 fix markers verified INSIDE the binary, staged `opencode-patched-1.18.32.exe`, `fixes:verify` all-ok, `OPENCODE_BINARY` (User) → staged binary. Config set: `~/.config/opencode/opencode.json` `agent.compaction = { model: "openai/gpt-6-luna", variant: "low" }`. +- Trap hit twice: manifest markers must be RUNTIME STRINGS (identifier `summarizerVariant` was minified away; string literal `compaction-model-override` survives). +- **After restart, verify live**: managed-opencode/.json records the staged exe; on next compaction the log line "compaction model resolved" shows `pin: compaction-model-override, variant: low` and luna-low summarizes. THEN do the canon update (mk-canon-update; the OMO per-agent compaction pins in omo.jsonc become redundant for sessions where the global pin suffices — owner decision). + +**OPEN BUG (not this fix)**: post-compaction continuation hijack — session `ses_f22570befffeKtPFEZc6tHsCBM` (main model kimi, OMO pin glm-5.3-flash) compacted on GLM at 2026-09-27T20:52Z, and the continuation ALSO ran on GLM. The OMO reroute DID fire (`[session-compacting] rerouted compaction autocontinue to the working model {reissued:true}` at 20:52:54.519) but resolved the working model to GLM — fix 02's "last real assistant turn" resolver (OMO repo, `plugin/compaction-autocontinue-reroute.ts` / `pickActiveSessionMessage`) picked a GLM message, most likely an OMO preemptive summarize output that is not tagged as compaction/summary. Fix direction: make the resolver skip messages whose model equals the compaction pin / whose agent is `compaction` / mode `compaction`, and fall through to the last genuine assistant turn. Needs OMO-repo TDD work. diff --git a/PR-DRAFT.md b/PR-DRAFT.md new file mode 100644 index 000000000000..208af9a5544f --- /dev/null +++ b/PR-DRAFT.md @@ -0,0 +1,126 @@ +# fix(session): make compaction summaries marker-safe and terminal + +## Summary + +`SessionCompaction` creates the compaction summary with `parentID: lastUser.id`. It should use the **marker** message — the user message that carries the `compaction` part and that the summary is answering. When a prompt arrives inside the marker→summary window, `lastUser` is that new prompt instead of the marker, and two things break at once: + +1. The summary is **orphaned** — its parent is not the marker, so `completedCompactions()` cannot see it and compaction can fire again. +2. The **model pin is discarded** — the summary is generated with the new prompt's model rather than the model the caller requested for compaction. + +Both symptoms come from the same line. This fixes the parent and removes the race. + +The follow-up commit in this branch also closes a second OpenCode core defect: +an incomplete summary stream was treated as a successful `continue`, allowing +the same marker to be selected repeatedly. The promotion bundle carries both +commits so an upstream update cannot restore either failure mode. + +## The bug + +`packages/opencode/src/session/prompt.ts` + +```diff +- parentID: lastUser.id, ++ parentID: task.messageID, // the marker, not whatever prompt arrived last +``` + +`task.messageID` is the message that owns the `compaction` part — the one the summarizer is answering. `lastUser` is resolved from the message list and is only *usually* the same thing. + +### Why the window exists + +Compaction is a two-step handshake: + +``` +T0 marker created (user message + `compaction` part) +T1 summarizer runs ← a prompt arriving here is the race +T2 summary written with parentID = ? +``` + +At `T2` the code asked "who is the last user message?" rather than "which message am I answering?". If the user typed anything during `T1`, those differ. + +## Evidence — reproduced from production + +Four compactions of the same session on `1.18.32`, read from `opencode.db`: + +| time (UTC) | `auto` | marker model | summary anchored to marker? | +|---|---|---|---| +| 2026-09-24 11:48:56 | true | `zai-coding-plan/glm-5.3-flash` | yes | +| 2026-09-24 11:56:57 | true | `zai-coding-plan/glm-5.3-flash` | yes | +| **2026-09-24 12:06:11** | true | — | **NO — no summary has `parentID` = marker** | +| **2026-09-25 11:39:22** | false | `opencode-go/deepseek-v4.1-flash` | **yes** (post-patch) | + +The 09-24 12:06 row is the bug: the summary was written, but not as a child of its marker, so it was invisible to the completion check. + +A rule-level replay against the real database confirms it: with the upstream rule, the recorded parent matched `lastUser` in **9/9** cases; with the marker rule, the selected parent was the marker in **9/9**. + +## The fix + +The commit touches one line in `prompt.ts` and the logic around it in `compaction.ts`: + +- **Pass the marker id** (`task.messageID`) as the summary's `parentID`. +- **Hard-reject a spent marker** — one that already carries a completed summary is not re-processed. This is what makes the loop impossible rather than merely unlikely. +- **Cut history at the marker index**, so a prompt that arrived after the marker stays live instead of being summarized and replayed. +- **Split the resume user id from the marker identity.** +- **Suppress the synthetic autocontinue** while a real prompt is waiting. Without this the resumed turn could swallow the prompt that caused the race. +- **Drop the forced tail** — a missing `tail_start_id` is a valid full compaction, not a signal to force one. +- **Label the model source in logs** (`configured-compaction-agent` vs `request-marker`), so a working pin is distinguishable from a dead one. + +A forced-tail fallback that appeared in an earlier revision was **removed** — review established its premise was false: `message-v2.ts:534` (`if (!part.tail_start_id) break`) already treats a marker without `tail_start_id` as a valid full compaction, so the fallback was both unnecessary and wrong. + +## Follow-up defect: unfinished summaries retried forever + +The anchor fix correctly binds a summary to its marker, but it cannot make a +provider stream that ends without `finish` successful. In the affected session, +the same marker acquired six `summary: true` assistant messages with no +`finish` and no error. `SessionCompaction.process` returned `continue`, +`prompt.ts` selected the marker again, and the loop emitted repeated compaction +events and injected continuations. + +Commit `6037275267` fixes this in `packages/opencode/src/session/compaction.ts`: + +- preserve an existing processor error; +- assign an `UnknownError` when the summary has no terminal finish; +- persist `finish: "error"`; and +- return `stop` before tail updates, autocontinue, or `Compacted` publication. + +The regression test proves an unfinished summary terminates and is persisted as +an error. Successful summaries still require and retain their terminal finish. + +## The gap that let the original bug run for 37 hours + +The original anchor bug went unnoticed so long because **there was no terminal +state on an incomplete compaction attempt**. The orphaned summary was invisible +to the completion check, so the marker looked unprocessed forever and compaction +re-fired indefinitely. One observed instance ran for **37 hours**. The two +commits now address both sides: correct marker identity and terminal failure. + +A summary that is written but not counted as completing its marker is a contradiction. Either the write should fail loudly, or the completion check should be anchored the same way the write is. A guard that counts *attempts per marker* and refuses after N would make this class of bug self-limiting regardless of cause. + +## Tests + +`packages/opencode/test/session/compaction.test.ts` covers both fixes, +including the unfinished-summary regression. `bun run typecheck` passes, and +the targeted anchor, terminal-state, overflow, event, and autocontinue tests +pass individually. + +The tests deliberately cover the race, not just the happy path: a marker with a follow-up prompt already present, a marker whose summary already exists, and a marker with no `tail_start_id`. + +## Risk + +Low, and the reasoning is that the change makes the parent *more* specific, never less: + +- In the common case — no prompt during the window — `lastUser` **is** the marker, so behaviour is unchanged. The 2026-09-25 11:39 row above is exactly this case, and it is the reason the model choice there is unaffected by this patch. +- In the race case the patch changes behaviour, and that is the point: the summary becomes a child of the marker it answers. + +The model-resolution expression itself is **byte-identical to upstream** — only the local variable name and a log label changed. This patch does not alter which model a given compaction uses; it alters which *message's* model is consulted, and only when they differ. + +## Reproduction + +``` +1. Start a session, let context approach the compaction threshold. +2. Trigger compaction. +3. Send any message while the summarizer is running. +4. Inspect: the summary's parentID will be the new prompt, not the marker. + `completedCompactions()` will not count it, and compaction can re-fire. +``` + +Against the patched build the same sequence anchors to the marker and terminates. diff --git a/bun.lock b/bun.lock index b97e2d9b553c..7b1f4b0e90c6 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.18.31", + "version": "1.18.32", "dependencies": { "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", @@ -96,7 +96,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.18.31", + "version": "1.18.32", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -144,7 +144,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "1.18.31", + "version": "1.18.32", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -158,7 +158,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.18.31", + "version": "1.18.32", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -195,7 +195,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.18.31", + "version": "1.18.32", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -222,7 +222,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.18.31", + "version": "1.18.32", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -245,7 +245,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.18.31", + "version": "1.18.32", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -269,7 +269,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.18.31", + "version": "1.18.32", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -289,7 +289,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.18.31", + "version": "1.18.32", "bin": { "opencode": "./bin/opencode", }, @@ -383,7 +383,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.18.31", + "version": "1.18.32", "dependencies": { "@zip.js/zip.js": "2.7.62", "drizzle-orm": "catalog:", @@ -437,7 +437,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.18.31", + "version": "1.18.32", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -451,7 +451,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.18.31", + "version": "1.18.32", "dependencies": { "effect": "catalog:", }, @@ -463,7 +463,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.18.31", + "version": "1.18.32", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -495,7 +495,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.18.31", + "version": "1.18.32", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -511,7 +511,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.18.31", + "version": "1.18.32", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -542,7 +542,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.18.31", + "version": "1.18.32", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -561,7 +561,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.18.31", + "version": "1.18.32", "bin": { "opencode": "./bin/opencode", }, @@ -692,7 +692,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.18.31", + "version": "1.18.32", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", @@ -768,7 +768,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.18.31", + "version": "1.18.32", "dependencies": { "cross-spawn": "catalog:", }, @@ -783,7 +783,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.18.31", + "version": "1.18.32", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -798,7 +798,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.18.31", + "version": "1.18.32", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz", @@ -838,7 +838,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.18.31", + "version": "1.18.32", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -851,7 +851,7 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.18.31", + "version": "1.18.32", "dependencies": { "@ibm/plex": "6.4.1", "@kobalte/core": "catalog:", @@ -878,7 +878,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.18.31", + "version": "1.18.32", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -897,7 +897,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.18.31", + "version": "1.18.32", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -939,7 +939,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.18.31", + "version": "1.18.32", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -966,7 +966,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.18.31", + "version": "1.18.32", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -1017,7 +1017,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.18.31", + "version": "1.18.32", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/local-fixes/apply.mjs b/local-fixes/apply.mjs new file mode 100644 index 000000000000..6d4b61c076a4 --- /dev/null +++ b/local-fixes/apply.mjs @@ -0,0 +1,271 @@ +#!/usr/bin/env bun +/** + * fixes:apply — upgrade the patched opencode binary to a newer upstream tag, + * re-applying every ordered local fix, then verify they survived bundling + * before staging it for OpenChamber. + * + * bun run fixes:apply # newest upstream v1.x tag + * bun run fixes:apply v1.18.33 # a specific tag + * bun run fixes:apply 1.18.33 # same, without the v + * + * Fix commits are IMMUTABLE. Every run creates a fresh build/ + * branch from the target tag and cherry-picks every manifest fix in order, so + * an upgrade can never rewrite or lose one, and a failed upgrade leaves the + * repo exactly as it was found. + */ +import { spawnSync } from "node:child_process" +import { existsSync, readFileSync, copyFileSync, readdirSync } from "node:fs" +import { join, dirname } from "node:path" +import { fileURLToPath } from "node:url" + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..") +const manifest = JSON.parse(readFileSync(join(ROOT, "local-fixes/manifest.json"), "utf8")) + +let step = 0 +const ok = (m) => console.log(` ok ${m}`) +const info = (m) => console.log(` ${m}`) +const fail = (m) => { + console.error(`\nFAILED: ${m}`) + process.exit(2) +} +const fixes = manifest.fixes ?? (manifest.fix ? [manifest.fix] : []) +if (!fixes.length) fail("manifest contains no local fixes") + +function git(args, opts = {}) { + const r = spawnSync("git", args, { cwd: ROOT, encoding: "utf8", ...opts }) + return { code: r.status, out: (r.stdout || "").trim(), err: (r.stderr || "").trim() } +} +function gitOrFail(args, what) { + const r = git(args) + if (r.code !== 0) fail(`${what}\n git ${args.join(" ")}\n ${r.err || r.out}`) + return r.out +} + +/** v1.18.32 -> [1,18,32]; returns null for anything not a plain v1.x.y tag */ +function parseTag(tag) { + const m = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag) + return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null +} +const cmp = (a, b) => (a[0] - b[0]) || (a[1] - b[1]) || (a[2] - b[2]) + +function newestV1Tag() { + const tags = gitOrFail(["tag", "--list", "v1.*"], "listing tags").split("\n").filter(Boolean) + const ranked = tags.map((t) => ({ t, v: parseTag(t) })).filter((x) => x.v).sort((a, b) => cmp(a.v, b.v)) + if (!ranked.length) fail("no v1.x.y tags found upstream") + return ranked[ranked.length - 1].t +} + +/** bytes contain the marker as a literal substring (bundled JS is utf8) */ +function binaryHasMarkers(file, markers) { + const buf = readFileSync(file) + const hay = buf.toString("latin1") + return markers.map((m) => ({ marker: m, found: hay.includes(m) })) +} + +console.log(`\n=== fixes:apply — opencode ${fixes.map((fix) => fix.id).join(", ")} ===`) +const startBranch = gitOrFail(["branch", "--show-current"], "reading current branch") + +// ---------------------------------------------------------------- 1. resolve target +step++ +console.log(`\n[${step}] resolve target version`) +const arg = process.argv[2] +let tag +if (arg) { + tag = arg.startsWith("v") ? arg : `v${arg}` +} else { + gitOrFail(["fetch", "origin", "--tags", "--quiet"], "fetching upstream tags") + tag = newestV1Tag() + info("no version given — using newest upstream v1.x tag") +} +const v = parseTag(tag) +if (!v) fail(`not a plain vX.Y.Z tag: ${tag}`) +if (v[0] !== 1) { + fail( + `refusing to target ${tag}: this repo only supports OpenCode v1.x.\n` + + ` ${manifest.upstreamConstraint}`, + ) +} +const version = `${v[0]}.${v[1]}.${v[2]}` +ok(`target ${tag} (version ${version})`) + +// ---------------------------------------------------------------- 2. preflight +step++ +console.log(`\n[${step}] preflight`) +if (git(["cat-file", "-e", `${tag}^{commit}`]).code !== 0) { + // the tag may not be fetched yet when an explicit version was passed + gitOrFail(["fetch", "origin", "--tags", "--quiet"], "fetching upstream tags") +} +if (git(["cat-file", "-e", `${tag}^{commit}`]).code !== 0) fail(`tag does not exist: ${tag}`) +for (const fix of fixes) { + if (git(["cat-file", "-e", `${fix.commit}^{commit}`]).code !== 0) { + fail(`fix commit is missing from this repo: ${fix.commit}`) + } + ok(`${fix.id} commit present: ${fix.commit.slice(0, 10)}`) +} + +const dirty = git(["status", "--porcelain"]) + .out.split("\n") + .filter(Boolean) + // build output is regenerated on every run; anything else is real work + .filter((l) => !/(^|[\s/])dist\//.test(l)) +if (dirty.length) { + fail(`working tree has changes beyond build output:\n${dirty.map((l) => " " + l).join("\n")}\n commit or stash them, then re-run.`) +} +ok("working tree clean") + +// ---------------------------------------------------------------- 3. build branch +step++ +console.log(`\n[${step}] create build branch from ${tag}`) +const buildBranch = `${manifest.buildBranchPrefix}${version}` +gitOrFail(["checkout", "-B", buildBranch, tag], `creating ${buildBranch}`) +ok(`on ${buildBranch}`) + +// ---------------------------------------------------------------- 4. re-apply every fix +step++ +console.log(`\n[${step}] re-apply every fix in manifest order`) +for (const fix of fixes) { + console.log(`\n ${fix.id}`) + let applied = false + const cp = git(["cherry-pick", "--no-gpg-sign", fix.commit]) + if (cp.code === 0) { + ok("cherry-pick clean") + applied = true + } else { + git(["cherry-pick", "--abort"]) + info("cherry-pick conflicted — trying the patch with 3-way merge") + const patchPath = join(ROOT, fix.patch) + if (!existsSync(patchPath)) { + gitOrFail(["checkout", startBranch], "restoring branch") + fail(`both cherry-pick and patch failed, and ${fix.patch} does not exist. Upstream moved the same code — resolve by hand.`) + } + const ap = git(["apply", "--3way", patchPath]) + if (ap.code === 0) { + gitOrFail(["add", "-A"], "staging patched files") + const c = git(["commit", "-q", "--no-gpg-sign", "-m", `${fix.title} (patch application)`]) + if (c.code !== 0) { + gitOrFail(["checkout", startBranch], "restoring branch") + fail("patch applied but commit failed") + } + ok("patch applied with 3-way merge") + applied = true + } + } + + if (!applied) { + git(["checkout", "--", "."]) + git(["checkout", startBranch], "restoring branch") + fail( + `CONFLICT: upstream moved the same code as ${fix.id} at ${tag}.\n` + + ` Nothing was changed — you are back on ${startBranch}.\n` + + ` Rebase ${fix.commit.slice(0, 10)} by hand, then update manifest.fixes.`, + ) + } +} + +// ---------------------------------------------------------------- 5. build +step++ +console.log(`\n[${step}] build with OPENCODE_VERSION=${version}`) +const buildDir = join(ROOT, manifest.build.cwd) +const b = spawnSync("bun", ["run", "script/build.ts", "--single", "--skip-embed-web-ui", "--skip-install"], { + cwd: buildDir, + encoding: "utf8", + env: { ...process.env, [manifest.build.versionEnv]: version }, + stdio: ["ignore", "pipe", "pipe"], +}) +if (b.status !== 0) { + console.error(b.stdout?.slice(-2000) || "") + console.error(b.stderr?.slice(-2000) || "") + gitOrFail(["checkout", startBranch], "restoring branch") + fail(`build failed (exit ${b.status})`) +} +ok("build succeeded") + +const artifact = join(ROOT, manifest.build.cwd, manifest.build.artifact) +if (!existsSync(artifact)) fail(`built artifact missing: ${artifact}`) + +// ---------------------------------------------------------------- 6. run all unique fix tests +step++ +const tests = [...new Set(fixes.map((fix) => fix.tests))] +console.log(`\n[${step}] run ${tests.join(", ")}`) +const t = spawnSync("bun", ["test", ...tests, "--timeout", "30000"], { + cwd: buildDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], +}) +const tOut = `${t.stdout || ""}\n${t.stderr || ""}` +const countOf = (word) => { + const m = tOut.match(new RegExp(`^\\s*(\\d+) ${word}\\s*$`, "m")) + return m ? Number(m[1]) : 0 +} +const passed = countOf("pass") +const skipped = countOf("skip") +const failed = countOf("fail") +info(`${passed} pass, ${skipped} skip, ${failed} fail`) +if (t.status !== 0 || failed > 0 || passed === 0) { + console.error(tOut.slice(-4000)) + gitOrFail(["checkout", startBranch], "restoring branch") + fail("the local fixes' tests did not pass on this base — refusing to stage a binary") +} +ok("tests pass") + +// ---------------------------------------------------------------- 7. verify markers in the artifact +step++ +console.log(`\n[${step}] verify fix markers inside the built binary`) +const results = fixes.flatMap((fix) => + binaryHasMarkers(artifact, fix.markers).map((result) => ({ ...result, fix: fix.id })), +) +for (const r of results) { + if (r.found) ok(`${r.fix}: marker present: ${r.marker}`) + else console.error(` MISSING (${r.fix}): ${r.marker}`) +} +const missing = results.filter((r) => !r.found) +if (missing.length) { + gitOrFail(["checkout", startBranch], "restoring branch") + fail(`binary is missing ${missing.length} marker(s) — one or more fixes did not survive bundling. Not staging.`) +} + +// ---------------------------------------------------------------- 8. stage +step++ +console.log(`\n[${step}] stage the binary`) +const stagePath = manifest.stage.path.replace("{version}", version) +copyFileSync(artifact, stagePath) +ok(`copied → ${stagePath}`) + +const vv = spawnSync(stagePath, ["--version"], { encoding: "utf8" }) +const reported = (vv.stdout || "").trim() +if (reported !== version) { + fail(`staged binary reports "${reported}" but ${version} was expected.\n If it says 0.x or a dev string, the OPENCODE_VERSION build env was lost.`) +} +ok(`staged binary reports ${reported}`) + +// ---------------------------------------------------------------- 9. settings check +step++ +console.log(`\n[${step}] check OpenChamber pin`) +const settingsFile = manifest.stage.settingsFile +if (existsSync(settingsFile)) { + try { + const s = JSON.parse(readFileSync(settingsFile, "utf8")) + const pinned = s[manifest.stage.settingsKey] + if (pinned === stagePath) ok(`settings.json ${manifest.stage.settingsKey} → staged binary`) + else + console.error( + ` WARNING: settings.json points at "${pinned || "(bundled)"}"\n` + + ` set "${manifest.stage.settingsKey}": "${stagePath.replace(/\\/g, "\\\\")}"\n` + + ` while OpenChamber is CLOSED, or the new binary will not be used.`, + ) + } catch (e) { + console.error(` WARNING: could not read ${settingsFile}: ${e.message}`) + } +} else { + info(`(no settings file at ${settingsFile})`) +} + +// ---------------------------------------------------------------- 10. restore branch +step++ +console.log(`\n[${step}] restore working branch`) +gitOrFail(["checkout", startBranch], `returning to ${startBranch}`) +ok(`back on ${startBranch} (build kept on ${buildBranch} for inspection)`) + +console.log(`\nAll steps passed. ${fixes.map((fix) => fix.id).join(", ")} applied and verified for opencode ${version}.`) +console.log(`${manifest.restartNote}`) +console.log(`Rollback: \`bun run fixes:apply ${fixes[0].base}\`, or delete ${stagePath} to fall back to the bundled binary.`) diff --git a/local-fixes/manifest.json b/local-fixes/manifest.json new file mode 100644 index 000000000000..b8edee49edaa --- /dev/null +++ b/local-fixes/manifest.json @@ -0,0 +1,108 @@ +{ + "version": 1, + "purpose": "Upgrade the patched opencode 1.x binary to a newer upstream tag while re-applying every ordered local fix, then verify all fixes survived bundling before staging it for OpenChamber.", + "repo": "opencode-core-fix", + "upstream": "https://github.com/anomalyco/opencode.git", + "branch": "fix/compaction-anchor", + "upstreamConstraint": "v1.x only. NEVER target v2+: packages/opencode/ does not exist there and OpenChamber 1.24.2 requires OpenCode 1.x.", + "fixes": [ + { + "id": "compaction-anchor", + "title": "Anchor the compaction summary to the marker message", + "commit": "b855924e87db342c69a3a4c26beefc77f4cdc858", + "patch": "local-fixes/patches/compaction-anchor.patch", + "base": "v1.18.32", + "files": [ + "packages/opencode/src/session/compaction.ts", + "packages/opencode/src/session/prompt.ts", + "packages/opencode/test/session/compaction.test.ts" + ], + "markers": [ + "configured-compaction-agent", + "request-marker", + "marker already carries a completed summary" + ], + "tests": "test/session/compaction.test.ts", + "why": "prompt.ts passed parentID: lastUser.id for the compaction summary, so a prompt landing in the marker-to-summary window orphaned the summary (infinite compaction) and discarded the marker's model (a plugin-supplied pin). Anchoring to the marker repairs both." + }, + { + "id": "compaction-terminal-state", + "title": "Stop unfinished compaction summaries instead of retrying", + "commit": "6037275267", + "patch": "local-fixes/patches/compaction-terminal-state.patch", + "base": "v1.18.32", + "files": [ + "packages/opencode/src/session/compaction.ts", + "packages/opencode/test/session/compaction.test.ts" + ], + "markers": [ + "Compaction stream ended without a terminal finish" + ], + "tests": "test/session/compaction.test.ts", + "why": "A summary stream that ended without finish was treated as continue, so the same marker was selected again indefinitely. Persisting an error finish and returning stop makes the failed marker terminal and prevents the retry storm." + }, + { + "id": "compaction-model-override", + "title": "Pin the compaction summarizer variant from the configured compaction agent", + "commit": "04a1cff12d587776cd5823ebc4ce3ceabacffbf2", + "patch": "local-fixes/patches/compaction-model-override.patch", + "base": "v1.18.32", + "files": [ + "packages/opencode/src/session/compaction.ts", + "packages/opencode/test/session/compaction.test.ts" + ], + "markers": [ + "compaction-model-override" + ], + "tests": "test/session/compaction.test.ts", + "why": "The compaction agent model pin existed (configured-compaction-agent) but the summarize kept the request marker variant, so a pinned model ran at the session variant (e.g. xhigh) instead of the agents configured variant (e.g. low). Threads agent.variant onto the summarize when a model is configured; logs pin+variant for runtime verification." + }, + { + "id": "compaction-pin-precedence", + "title": "Plugin pin beats the configured compaction agent; the agent is the overflow fallback", + "commit": "286c9d2ef2405a39fc7062d31969cc77a78272ab", + "patch": "local-fixes/patches/compaction-pin-precedence.patch", + "base": "v1.18.32", + "files": [ + "packages/opencode/src/session/compaction.ts", + "packages/opencode/test/session/compaction.test.ts" + ], + "markers": [ + "compaction-pin-precedence" + ], + "tests": "test/session/compaction.test.ts", + "why": "compaction-model-override made the opencode.json agent.compaction model beat every marker, which also overrode explicit plugin pins (OMO agents..compaction.model arrives as a marker model DIFFERENT from the working model). Precedence now: explicit pin (marker != working model) > configured compaction agent > bare marker. Core-initiated overflow markers echo the working model, so they fall through to the configured agent (luna-low); OMO preemptive pins win again. Working model = last real user turn before the marker (same rule as resume identity).", + "overlapsWith": [ + "compaction-model-override" + ], + "orderNote": "Must apply after compaction-model-override (rewrites the same resolution block)." + }, + { + "id": "empty-assistant-guard", + "title": "Drop assistant messages with no provider content (fix5)", + "commit": "d6ac80bb70d30ae69dae9bea6fab447910110103", + "patch": "local-fixes/patches/empty-assistant-guard.patch", + "test": "packages/opencode/src/session/message-v2.fix5.test.ts", + "markers": [], + "orderNote": "markers: [] (pure conversion-condition change, no runtime strings); verification = message-v2.fix5.test.ts (11 cases) + existing message-v2 suite 39/39. Single squashed commit 79542ac1e9 (initial + review hardening F1/F2/F3). Upstream: anomalyco/opencode#53571 / PR #53575." + } + ], + "build": { + "cwd": "packages/opencode", + "command": "bun run script/build.ts --single --skip-embed-web-ui --skip-install", + "versionEnv": "OPENCODE_VERSION", + "artifact": "dist/opencode-windows-x64/bin/opencode.exe", + "note": "OPENCODE_VERSION must be set or the binary reports a dev version and free-tier models reject it with 426 'OpenCode 1.18.0 or newer is required'." + }, + "stage": { + "path": "C:\\Users\\Zephyrus\\.local\\bin\\opencode-patched-{version}.exe", + "note": "Versioned filename on purpose: Windows locks the running binary, so overwriting one stable file fails with EBUSY while OpenChamber is up. Each build stages to opencode-patched-.exe; OPENCODE_BINARY is repointed at it (future launches only, running sessions untouched).", + "pinMechanism": "OPENCODE_BINARY, User scope. OpenChamber resolves its binary at startup as `process.env.OPENCODE_BINARY || searchPathFor('opencode')` - the env var is read FIRST and is the only durable pin.", + "settingsFile": "C:\\Users\\Zephyrus\\.config\\openchamber\\settings.json", + "settingsKey": "opencodeBinary", + "settingsKeyNote": "COSMETIC ONLY. OpenChamber drops this key on every settings rewrite (observed 2026-09-25 at 18:57:50 and 19:10:49) and never reads it at startup. Do not rely on it." + }, + "buildBranchPrefix": "build/", + "pinScript": "local-fixes/pin-opencode.ps1", + "restartNote": "OpenChamber must be restarted to load a newly staged binary - the env var is read at startup. Verify with managed-opencode/.json: it must record the staged path, not ...\\resources\\opencode-cli\\opencode.exe." +} diff --git a/local-fixes/patches/compaction-anchor.patch b/local-fixes/patches/compaction-anchor.patch new file mode 100644 index 000000000000..f1bcccbde266 --- /dev/null +++ b/local-fixes/patches/compaction-anchor.patch @@ -0,0 +1,358 @@ +From b855924e87db342c69a3a4c26beefc77f4cdc858 Mon Sep 17 00:00:00 2001 +From: "Mohamed R. Khalil" +Date: Fri, 25 Sep 2026 13:23:47 +0300 +Subject: [PATCH] fix(session): anchor the compaction summary to the marker + message + +prompt.ts passed `parentID: lastUser.id` when creating a compaction +summary. When a prompt arrived between the compaction marker and the +summary, the summary was orphaned: the read-side gate only honours a +summary whose parent resolves to a user message carrying the compaction +part, so compaction re-fired indefinitely and the marker's model (how a +plugin-supplied pin arrives) was discarded in favour of the session +model. One symptom or the other, depending on timing. + +Anchor the summary to the marker instead: + +- pass the marker id (task.messageID) as the summary's parentID +- hard-reject a marker that already carries a completed summary +- cut history at the marker index, so a prompt that arrived after the + marker stays live instead of being summarized and replayed +- split the resume user id from the marker identity +- suppress the synthetic autocontinue while a real prompt is waiting +- drop the forced tail; a missing tail_start_id is a valid full + compaction, not a signal to force one +- label the model source in logs (configured-compaction-agent vs + request-marker) so a working pin is distinguishable from a dead one +--- + packages/opencode/src/session/compaction.ts | 78 +++++++-- + packages/opencode/src/session/prompt.ts | 7 +- + .../opencode/test/session/compaction.test.ts | 156 ++++++++++++++++++ + 3 files changed, 230 insertions(+), 11 deletions(-) + +diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts +index 75d6374bfa..7affc37498 100644 +--- a/packages/opencode/src/session/compaction.ts ++++ b/packages/opencode/src/session/compaction.ts +@@ -329,6 +329,32 @@ const layer = Layer.effect( + } + const userMessage = parent.info + const compactionPart = parent.parts.find((part): part is SessionV1.CompactionPart => part.type === "compaction") ++ // completedCompactions() only honours a summary whose parentID resolves to a user message ++ // carrying a compaction part. SessionPrompt now always passes the marker's own id, so a ++ // markerless parent means a caller broke the contract: say so loudly rather than searching ++ // nearby for a marker, because a guess can bind an already-consumed one and leave two ++ // summaries answering a single compaction. ++ if (!compactionPart) { ++ yield* Effect.logError("compaction parent carries no marker; this summary will not be readable", { ++ sessionID: input.sessionID, ++ parentID: input.parentID, ++ }) ++ } ++ const alreadyCompleted = input.messages.some( ++ (m) => ++ m.info.role === "assistant" && ++ m.info.summary === true && ++ m.info.finish !== undefined && ++ !m.info.error && ++ m.info.parentID === input.parentID, ++ ) ++ if (alreadyCompleted) { ++ yield* Effect.logWarning("compaction marker already carries a completed summary; skipping", { ++ sessionID: input.sessionID, ++ parentID: input.parentID, ++ }) ++ return "continue" ++ } + + let messages = input.messages + let replay: +@@ -356,11 +382,26 @@ const layer = Layer.effect( + } + + const agent = yield* agents.get("compaction") +- const model = agent.model +- ? yield* provider.getModel(agent.model.providerID, agent.model.modelID).pipe(Effect.orDie) ++ const configuredModel = agent.model ++ const model = configuredModel ++ ? yield* provider.getModel(configuredModel.providerID, configuredModel.modelID).pipe(Effect.orDie) + : yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID).pipe(Effect.orDie) ++ // userMessage is the marker, so its model is the model this compaction was REQUESTED with ++ // (a plugin-supplied pin arrives exactly this way). It is not "the session model", and ++ // labelling it as such makes a working pin indistinguishable from a dead one in the logs. ++ yield* Effect.logInfo("compaction model resolved", { ++ source: configuredModel ? "configured-compaction-agent" : "request-marker", ++ providerID: model.providerID, ++ modelID: model.id, ++ requestedProviderID: userMessage.model.providerID, ++ requestedModelID: userMessage.model.modelID, ++ }) + const cfg = yield* config.get() +- const history = compactionPart && messages.at(-1)?.info.id === input.parentID ? messages.slice(0, -1) : messages ++ // The marker is the snapshot boundary. Everything at or after it is outside this ++ // compaction: the marker is bookkeeping, and a prompt that arrived after it must stay live ++ // rather than be summarized into history and then replayed as a live turn as well. ++ const markerIndex = messages.findIndex((m) => m.info.id === input.parentID) ++ const history = markerIndex >= 0 ? messages.slice(0, markerIndex) : messages + const prior = completedCompactions(history) + const hidden = new Set(prior.flatMap((item) => [item.userIndex, item.assistantIndex])) + const previousSummary = prior.at(-1)?.summary +@@ -465,6 +506,23 @@ const layer = Layer.effect( + }) + } + ++ // The marker is a compaction request, not a turn to resume. Resume identity is the last ++ // real user turn BEFORE the marker - taking it from the marker instead hands the ++ // summarizer's model, agent and variant to the live session after compaction. ++ const resumeCandidate = history.findLast( ++ (m): m is SessionV1.WithParts & { info: SessionV1.User } => ++ m.info.role === "user" && !m.parts.some((p) => p.type === "compaction"), ++ ) ++ const resumeUser = resumeCandidate?.info ?? userMessage ++ // If a real prompt arrived after the marker it is already queued to be answered. Injecting ++ // a synthetic continuation as well answers one turn twice. ++ const markerIndexInInput = input.messages.findIndex((m) => m.info.id === input.parentID) ++ const postMarkerPrompt = ++ markerIndexInInput >= 0 && ++ input.messages ++ .slice(markerIndexInInput + 1) ++ .some((m) => m.info.role === "user" && !m.parts.some((p) => p.type === "compaction")) ++ + if (result === "continue" && input.auto) { + if (replay) { + const original = replay.info +@@ -494,23 +552,23 @@ const layer = Layer.effect( + } + } + +- if (!replay) { +- const info = yield* provider.getProvider(userMessage.model.providerID) ++ if (!replay && !postMarkerPrompt) { ++ const info = yield* provider.getProvider(resumeUser.model.providerID) + if ( + (yield* plugin.trigger( + "experimental.compaction.autocontinue", + { + sessionID: input.sessionID, +- agent: userMessage.agent, ++ agent: resumeUser.agent, + model: yield* provider +- .getModel(userMessage.model.providerID, userMessage.model.modelID) ++ .getModel(resumeUser.model.providerID, resumeUser.model.modelID) + .pipe(Effect.orDie), + provider: { + source: info.source, + info, + options: info.options, + }, +- message: userMessage, ++ message: resumeUser, + overflow: input.overflow === true, + }, + { enabled: true }, +@@ -521,8 +579,8 @@ const layer = Layer.effect( + role: "user", + sessionID: input.sessionID, + time: { created: Date.now() }, +- agent: userMessage.agent, +- model: userMessage.model, ++ agent: resumeUser.agent, ++ model: resumeUser.model, + }) + const text = + (input.overflow +diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts +index 0f85d44f20..e0ab525b5e 100644 +--- a/packages/opencode/src/session/prompt.ts ++++ b/packages/opencode/src/session/prompt.ts +@@ -1149,7 +1149,12 @@ const layer = Layer.effect( + if (task?.type === "compaction") { + const result = yield* compaction.process({ + messages: msgs, +- parentID: lastUser.id, ++ // The task IS the compaction part, and every part carries its own messageID. ++ // Using lastUser.id instead means a prompt that arrives between the marker being ++ // written and this running becomes the summary's parent, which makes the summary ++ // unreadable to completedCompactions() and discards the model the compaction was ++ // requested with. ++ parentID: task.messageID, + sessionID, + auto: task.auto, + overflow: task.overflow, +diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts +index c76dd98b86..ab084a22da 100644 +--- a/packages/opencode/test/session/compaction.test.ts ++++ b/packages/opencode/test/session/compaction.test.ts +@@ -189,6 +189,7 @@ function createCompactionMarker(sessionID: SessionID) { + type: "compaction", + auto: false, + }) ++ return msg + }), + ) + } +@@ -1660,6 +1661,161 @@ describe("session.compaction.process", () => { + expect(part?.tail_start_id).toBe(keep.id) + }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 500 }) })), + ) ++ ++ // --- mid-compaction prompt race ------------------------------------------------------- ++ // A prompt landing between the marker and the summarize used to become the summary's parent, ++ // which made the summary unreadable, discarded the requested model, and replayed the whole ++ // history on every later turn. ++ ++ itCompaction.instance( ++ "anchors the summary to the marker and leaves a mid-compaction prompt out of the summary", ++ () => { ++ const stub = llm() ++ let captured = "" ++ stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages)))) ++ return Effect.gen(function* () { ++ const ssn = yield* SessionNs.Service ++ const session = yield* ssn.create({}) ++ yield* createUserMessage(session.id, "earlier real work") ++ yield* createCompactionMarker(session.id) ++ ++ const afterMarker = yield* ssn.messages({ sessionID: session.id }) ++ const markerID = afterMarker.at(-1)!.info.id ++ ++ // the prompt that arrives while the summarize is in flight ++ const typed = yield* createUserMessage(session.id, "WAIT-LOOK-AT-THIS") ++ ++ const msgs = yield* ssn.messages({ sessionID: session.id }) ++ // SessionPrompt now passes the compaction part's own messageID, not the last user id ++ yield* SessionCompaction.use.process({ ++ parentID: markerID, ++ messages: msgs, ++ sessionID: session.id, ++ auto: false, ++ }) ++ ++ const after = yield* ssn.messages({ sessionID: session.id }) ++ const summaryMsg = after.findLast((m) => m.info.role === "assistant" && m.info.summary === true) ++ expect(summaryMsg).toBeTruthy() ++ const summaryInfo = summaryMsg!.info as SessionV1.Assistant ++ ++ // 1. readable by completedCompactions() ++ expect(summaryInfo.parentID).toBe(markerID) ++ // 2. the mid-compaction prompt is NOT swallowed into the summarized history ++ expect(captured).toContain("earlier real work") ++ expect(captured).not.toContain("WAIT-LOOK-AT-THIS") ++ // 3. and it is still present as a live message ++ expect(after.some((m) => m.info.id === typed.id)).toBe(true) ++ }).pipe(withCompaction({ llm: stub.llmLayer })) ++ }, ++ { git: true }, ++ ) ++ ++ itCompaction.instance( ++ "never re-anchors a markerless parent onto an earlier, already-used marker", ++ () => { ++ const stub = llm() ++ stub.push(reply("second summary")) ++ return Effect.gen(function* () { ++ const test = yield* TestInstance ++ const ssn = yield* SessionNs.Service ++ const session = yield* ssn.create({}) ++ yield* createUserMessage(session.id, "first") ++ yield* createCompactionMarker(session.id) ++ const afterMarker = yield* ssn.messages({ sessionID: session.id }) ++ const firstMarkerID = afterMarker.at(-1)!.info.id ++ yield* createSummaryAssistantMessage(session.id, firstMarkerID, test.directory, "first summary") ++ ++ // a later compaction whose parent is a plain prompt, with a spent marker behind it ++ const plain = yield* createUserMessage(session.id, "a plain prompt, no marker") ++ const msgs = yield* ssn.messages({ sessionID: session.id }) ++ yield* SessionCompaction.use.process({ ++ parentID: plain.id, ++ messages: msgs, ++ sessionID: session.id, ++ auto: false, ++ }) ++ ++ const after = yield* ssn.messages({ sessionID: session.id }) ++ const summaries = after.filter((m) => m.info.role === "assistant" && m.info.summary === true) ++ expect(summaries.length).toBe(2) ++ // Searching backwards for "the nearest marker" would bind the spent one and leave two ++ // summaries answering a single compaction. The contract is honesty about the parent we ++ // were given, not a guess that corrupts completedCompactions(). ++ const second = summaries.at(-1)!.info as SessionV1.Assistant ++ expect(second.parentID).toBe(plain.id) ++ expect(second.parentID).not.toBe(firstMarkerID) ++ }).pipe(withCompaction({ llm: stub.llmLayer })) ++ }, ++ { git: true }, ++ ) ++ ++ itCompaction.instance( ++ "does not consume a marker that already carries a completed summary", ++ () => { ++ const stub = llm() ++ stub.push(reply("second summary")) ++ return Effect.gen(function* () { ++ const test = yield* TestInstance ++ const ssn = yield* SessionNs.Service ++ const session = yield* ssn.create({}) ++ yield* createUserMessage(session.id, "work") ++ yield* createCompactionMarker(session.id) ++ const afterMarker = yield* ssn.messages({ sessionID: session.id }) ++ const markerID = afterMarker.at(-1)!.info.id ++ yield* createSummaryAssistantMessage(session.id, markerID, test.directory, "first summary") ++ ++ const msgs = yield* ssn.messages({ sessionID: session.id }) ++ const result = yield* SessionCompaction.use.process({ ++ parentID: markerID, ++ messages: msgs, ++ sessionID: session.id, ++ auto: false, ++ }) ++ ++ expect(result).toBe("continue") ++ const after = yield* ssn.messages({ sessionID: session.id }) ++ const summaries = after.filter((m) => m.info.role === "assistant" && m.info.summary === true) ++ // two summaries against one marker give completedCompactions() duplicate userIndex rows ++ expect(summaries.length).toBe(1) ++ }).pipe(withCompaction({ llm: stub.llmLayer })) ++ }, ++ { git: true }, ++ ) ++ ++ itCompaction.instance( ++ "suppresses the synthetic continuation when a real prompt is already waiting", ++ () => { ++ const stub = llm() ++ stub.push(reply("summary")) ++ return Effect.gen(function* () { ++ const ssn = yield* SessionNs.Service ++ const session = yield* ssn.create({}) ++ yield* createUserMessage(session.id, "work") ++ yield* createCompactionMarker(session.id) ++ const afterMarker = yield* ssn.messages({ sessionID: session.id }) ++ const markerID = afterMarker.at(-1)!.info.id ++ const typed = yield* createUserMessage(session.id, "the real follow-up") ++ ++ const msgs = yield* ssn.messages({ sessionID: session.id }) ++ yield* SessionCompaction.use.process({ ++ parentID: markerID, ++ messages: msgs, ++ sessionID: session.id, ++ auto: true, ++ }) ++ ++ const after = yield* ssn.messages({ sessionID: session.id }) ++ const synthetic = after.filter((m) => ++ m.parts.some((part) => part.type === "text" && part.synthetic === true), ++ ) ++ // the waiting prompt answers this turn; a synthetic continue would answer it twice ++ expect(synthetic.length).toBe(0) ++ expect(after.some((m) => m.info.id === typed.id)).toBe(true) ++ }).pipe(withCompaction({ llm: stub.llmLayer })) ++ }, ++ { git: true }, ++ ) + }) + + describe("util.token.estimate", () => { +-- +2.55.0.windows.3 + diff --git a/local-fixes/patches/compaction-model-override.patch b/local-fixes/patches/compaction-model-override.patch new file mode 100644 index 000000000000..579876ab3963 Binary files /dev/null and b/local-fixes/patches/compaction-model-override.patch differ diff --git a/local-fixes/patches/compaction-pin-precedence.patch b/local-fixes/patches/compaction-pin-precedence.patch new file mode 100644 index 000000000000..16efd10227e2 --- /dev/null +++ b/local-fixes/patches/compaction-pin-precedence.patch @@ -0,0 +1,171 @@ +From 286c9d2ef2405a39fc7062d31969cc77a78272ab Mon Sep 17 00:00:00 2001 +From: "Mohamed R. Khalil" +Date: Mon, 28 Sep 2026 01:30:00 +0300 +Subject: [PATCH] fix(compaction): plugin pin beats configured agent; + configured agent is the overflow fallback (compaction-pin-precedence) + +--- + packages/opencode/src/session/compaction.ts | 67 ++++++++++++++++--- + .../opencode/test/session/compaction.test.ts | 59 ++++++++++++++++ + 2 files changed, 116 insertions(+), 10 deletions(-) + +diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts +index 21f8069776..4fd30932b6 100644 +--- a/packages/opencode/src/session/compaction.ts ++++ b/packages/opencode/src/session/compaction.ts +@@ -85,6 +85,45 @@ const serialize = (message: SessionV1.WithParts) => { + .join("\n") + } + ++// local-fix: compaction-pin-precedence — decide which model summarizes. An ++// explicit plugin pin arrives on the marker as a model DIFFERENT from the working ++// model; a core-initiated overflow marker merely echoes the working model. The pin ++// wins, the configured compaction agent is the fallback, and the bare marker is the ++// last resort (pre-plugin behavior). ++export type SummarizerModelRef = { ++ readonly providerID: string ++ readonly modelID: string ++ readonly variant?: string ++} ++ ++const sameModelRef = (a?: { providerID?: string; modelID?: string }, b?: { providerID?: string; modelID?: string }) => ++ !!a && !!b && a.providerID === b.providerID && a.modelID === b.modelID ++ ++export const resolveSummarizerModelRef = (input: { ++ readonly configured?: T ++ readonly marker: T ++ readonly working?: { readonly providerID: string; readonly modelID: string } ++}): { readonly source: "pin" | "configured" | "marker"; readonly model: T } => { ++ if (input.marker && !sameModelRef(input.marker, input.working)) { ++ return { source: "pin", model: input.marker } ++ } ++ if (input.configured) { ++ return { source: "configured", model: input.configured } ++ } ++ return { source: "marker", model: input.marker } ++} ++ ++// The working model is the last real user turn before the marker, mirroring the ++// resume-identity rule below. ++export const resolveWorkingModelRef = (messages: SessionV1.WithParts[], parentID: MessageID) => { ++ const markerIndex = messages.findIndex((m) => m.info.id === parentID) ++ const history = markerIndex >= 0 ? messages.slice(0, markerIndex) : messages ++ const candidate = history.findLast( ++ (m): m is SessionV1.WithParts & { info: SessionV1.User } => ++ m.info.role === "user" && !m.parts.some((part) => part.type === "compaction"), ++ ) ++ return candidate?.info.model ++} + function summaryText(message: SessionV1.WithParts) { + const text = message.parts + .filter((part): part is SessionV1.TextPart => part.type === "text") +@@ -383,20 +422,28 @@ const layer = Layer.effect( + } + + const agent = yield* agents.get("compaction") +- const configuredModel = agent.model +- const model = configuredModel +- ? yield* provider.getModel(configuredModel.providerID, configuredModel.modelID).pipe(Effect.orDie) +- : yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID).pipe(Effect.orDie) +- // local-fix: compaction-model-override — when the compaction agent pins a +- // model, its variant pins with it; the marker's variant must not leak onto +- // a different model family. +- const summarizerVariant = configuredModel ? agent.variant : userMessage.model.variant ++ // local-fix: compaction-pin-precedence — an explicit plugin pin (a marker ++ // model different from the working model) is the requested summarizer and ++ // beats the configured compaction agent; the configured agent is the fallback ++ // when the marker merely echoes the working model (core-initiated overflow). ++ const summarizerDecision = resolveSummarizerModelRef({ ++ configured: agent.model, ++ marker: userMessage.model, ++ working: resolveWorkingModelRef(input.messages, input.parentID), ++ }) ++ const model = yield* provider ++ .getModel(summarizerDecision.model.providerID, summarizerDecision.model.modelID) ++ .pipe(Effect.orDie) ++ const summarizerVariant = ++ summarizerDecision.source === "configured" ? agent.variant : userMessage.model.variant + // userMessage is the marker, so its model is the model this compaction was REQUESTED with + // (a plugin-supplied pin arrives exactly this way). It is not "the session model", and + // labelling it as such makes a working pin indistinguishable from a dead one in the logs. + yield* Effect.logInfo("compaction model resolved", { +- source: configuredModel ? "configured-compaction-agent" : "request-marker", +- pin: configuredModel ? "compaction-model-override" : undefined, ++ source: summarizerDecision.source === "configured" ? "configured-compaction-agent" : "request-marker", ++ pin: summarizerDecision.source === "configured" ? "compaction-model-override" : undefined, ++ precedence: summarizerDecision.source, ++ precedenceRule: "compaction-pin-precedence", + variant: summarizerVariant, + providerID: model.providerID, + modelID: model.id, +diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts +index b403c39e22..c66e5a4690 100644 +--- a/packages/opencode/test/session/compaction.test.ts ++++ b/packages/opencode/test/session/compaction.test.ts +@@ -2207,3 +2207,62 @@ describe("SessionNs.getUsage", () => { + expect(result.tokens.cache.write).toBe(300) + }) + }) ++ ++ ++// --- local-fix: compaction-pin-precedence ------------------------------------------------ ++// An explicit plugin pin on the marker (a model different from the working model) ++// is the requested summarizer and beats the configured compaction agent. The ++// configured agent is the fallback when the marker merely echoes the working ++// model (core-initiated overflow) or is absent. ++ ++describe("resolveSummarizerModelRef (compaction-pin-precedence)", () => { ++ const ref = (providerID: string, modelID: string, variant?: string) => ({ providerID, modelID, ...(variant ? { variant } : {}) }) ++ const resolve = SessionCompaction.resolveSummarizerModelRef ++ ++ test("plugin pin on the marker beats the configured compaction agent", () => { ++ const decision = resolve({ ++ configured: ref("openai", "gpt-6-luna"), ++ marker: ref("zai-coding-plan", "glm-5.3-flash"), ++ working: ref("kimi-for-coding", "k3-256k"), ++ }) ++ expect(decision.source).toBe("pin") ++ expect(decision.model).toEqual(ref("zai-coding-plan", "glm-5.3-flash")) ++ }) ++ ++ test("marker echoing the working model falls back to the configured agent", () => { ++ const decision = resolve({ ++ configured: ref("openai", "gpt-6-luna"), ++ marker: ref("kimi-for-coding", "k3-256k"), ++ working: ref("kimi-for-coding", "k3-256k"), ++ }) ++ expect(decision.source).toBe("configured") ++ expect(decision.model).toEqual(ref("openai", "gpt-6-luna")) ++ }) ++ ++ test("marker echoing the working model with no configured agent keeps the marker", () => { ++ const decision = resolve({ ++ marker: ref("kimi-for-coding", "k3-256k"), ++ working: ref("kimi-for-coding", "k3-256k"), ++ }) ++ expect(decision.source).toBe("marker") ++ expect(decision.model).toEqual(ref("kimi-for-coding", "k3-256k")) ++ }) ++ ++ test("marker with no resolvable working model is treated as an explicit pin", () => { ++ const decision = resolve({ ++ configured: ref("openai", "gpt-6-luna"), ++ marker: ref("zai-coding-plan", "glm-5.3-flash"), ++ }) ++ expect(decision.source).toBe("pin") ++ expect(decision.model).toEqual(ref("zai-coding-plan", "glm-5.3-flash")) ++ }) ++ ++ test("pin carries the marker variant; configured carries the agent variant slot", () => { ++ const pin = resolve({ ++ configured: ref("openai", "gpt-6-luna"), ++ marker: { providerID: "zai-coding-plan", modelID: "glm-5.3-flash", variant: "turbo" }, ++ working: ref("kimi-for-coding", "k3-256k"), ++ }) ++ expect(pin.model).toEqual({ providerID: "zai-coding-plan", modelID: "glm-5.3-flash", variant: "turbo" }) ++ }) ++}) +\ No newline at end of file +-- +2.55.0.windows.3 + diff --git a/local-fixes/patches/compaction-terminal-state.patch b/local-fixes/patches/compaction-terminal-state.patch new file mode 100644 index 000000000000..d6ac794b6ae4 --- /dev/null +++ b/local-fixes/patches/compaction-terminal-state.patch @@ -0,0 +1,181 @@ +From 60372752674fe3a46e5d54b0fc56768e43aef59c Mon Sep 17 00:00:00 2001 +From: "Mohamed R. Khalil" +Date: Sat, 26 Sep 2026 01:00:06 +0300 +Subject: [PATCH] fix(session): stop unfinished compaction retries + +--- + packages/opencode/src/session/compaction.ts | 10 ++++ + .../opencode/test/session/compaction.test.ts | 50 +++++++++++++++---- + 2 files changed, 49 insertions(+), 11 deletions(-) + +diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts +index 7affc37498..98ab7a2d6c 100644 +--- a/packages/opencode/src/session/compaction.ts ++++ b/packages/opencode/src/session/compaction.ts +@@ -6,6 +6,7 @@ import { SessionID, MessageID, PartID } from "./schema" + import { Provider } from "@/provider/provider" + import { MessageV2 } from "./message-v2" + import { Token } from "@/util/token" ++import { NamedError } from "@opencode-ai/core/util/error" + import { SessionProcessor } from "./processor" + import { Agent } from "@/agent/agent" + import { Plugin } from "@/plugin" +@@ -499,6 +500,15 @@ const layer = Layer.effect( + return "stop" + } + ++ if (result === "stop" || processor.message.error || processor.message.finish === undefined) { ++ processor.message.error ??= new NamedError.Unknown({ ++ message: "Compaction stream ended without a terminal finish", ++ }).toObject() ++ processor.message.finish = "error" ++ yield* session.updateMessage(processor.message) ++ return "stop" ++ } ++ + if (compactionPart && selected.tail_start_id && compactionPart.tail_start_id !== selected.tail_start_id) { + yield* session.updatePart({ + ...compactionPart, +diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts +index ab084a22da..bac8cd5bfb 100644 +--- a/packages/opencode/test/session/compaction.test.ts ++++ b/packages/opencode/test/session/compaction.test.ts +@@ -197,8 +197,10 @@ function createCompactionMarker(sessionID: SessionID) { + function fake( + input: Parameters[0], + result: "continue" | "compact", ++ terminal = true, + ) { + const msg = input.assistantMessage ++ if (result === "continue" && terminal) msg.finish = "end_turn" + return { + get message() { + return msg +@@ -209,11 +211,11 @@ function fake( + } satisfies SessionProcessorModule.SessionProcessor.Handle + } + +-function processorLayer(result: "continue" | "compact") { ++function processorLayer(result: "continue" | "compact", terminal = true) { + return Layer.succeed( + SessionProcessorModule.SessionProcessor.Service, + SessionProcessorModule.SessionProcessor.Service.of({ +- create: Effect.fn("TestSessionProcessor.create")((input) => Effect.succeed(fake(input, result))), ++ create: Effect.fn("TestSessionProcessor.create")((input) => Effect.succeed(fake(input, result, terminal))), + }), + ) + } +@@ -247,6 +249,7 @@ const itCompaction = testEffect(compactionEnv) + + type CompactionProcessOptions = { + result?: "continue" | "compact" ++ terminal?: boolean + llm?: Layer.Layer + plugin?: Layer.Layer + provider?: ReturnType +@@ -266,7 +269,7 @@ function compactionProcessLayer(options?: CompactionProcessOptions) { + if (!options?.llm) { + return AppNodeBuilder.build(compactionTestNode, [ + ...replacements, +- [SessionProcessorModule.SessionProcessor.node, processorLayer(options?.result ?? "continue")], ++ [SessionProcessorModule.SessionProcessor.node, processorLayer(options?.result ?? "continue", options?.terminal ?? true)], + ...(options?.plugin ? ([[Plugin.node, options.plugin]] as const) : []), + ...(options?.config ? ([[Config.node, options.config]] as const) : []), + ]) +@@ -843,7 +846,7 @@ describe("session.compaction.process", () => { + }), + ) + +- it.instance( ++ itCompaction.instance( + "publishes compacted event on continue", + Effect.gen(function* () { + const events = yield* EventV2Bridge.Service +@@ -851,14 +854,12 @@ describe("session.compaction.process", () => { + const session = yield* ssn.create({}) + const msg = yield* createUserMessage(session.id, "hello") + const msgs = yield* ssn.messages({ sessionID: session.id }) +- const done = yield* Deferred.make() + const seen: string[] = [] + const unsub = yield* events.listen((evt) => { + seen.push(evt.type) + if (evt.type !== SessionCompaction.Event.Compacted.type) return Effect.void + if ((evt.data as typeof SessionCompaction.Event.Compacted.data.Type).sessionID !== session.id) + return Effect.void +- Deferred.doneUnsafe(done, Effect.void) + return Effect.void + }) + yield* Effect.addFinalizer(() => unsub) +@@ -870,11 +871,10 @@ describe("session.compaction.process", () => { + auto: false, + }) + +- yield* Deferred.await(done).pipe(Effect.timeout("500 millis")) + expect(result).toBe("continue") + expect(seen).toContain(SessionCompaction.Event.Compacted.type) + expect(seen.filter((type) => type.startsWith("session.next."))).toEqual([]) +- }), ++ }).pipe(withCompaction({ plugin: autocontinue(false) })), + ) + + itCompaction.instance( +@@ -902,10 +902,10 @@ describe("session.compaction.process", () => { + expect(summary.info.finish).toBe("error") + expect(JSON.stringify(summary.info.error)).toContain("Session too large to compact") + } +- }).pipe(withCompaction({ result: "compact" })), ++ }).pipe(withCompaction({ result: "compact", plugin: autocontinue(false) })), + ) + +- it.instance( ++ itCompaction.instance( + "adds synthetic continue prompt when auto is enabled", + Effect.gen(function* () { + const ssn = yield* SessionNs.Service +@@ -933,7 +933,7 @@ describe("session.compaction.process", () => { + if (last?.parts[0]?.type === "text") { + expect(last.parts[0].text).toContain("Continue if you have next steps") + } +- }), ++ }).pipe(withCompaction({ plugin: autocontinue(true) })), + ) + + itCompaction.instance( +@@ -1816,6 +1816,34 @@ describe("session.compaction.process", () => { + }, + { git: true }, + ) ++ ++ itCompaction.instance( ++ "stops when the summary stream ends without a terminal finish", ++ () => ++ Effect.gen(function* () { ++ const ssn = yield* SessionNs.Service ++ const session = yield* ssn.create({}) ++ yield* createUserMessage(session.id, "work before compaction") ++ const marker = yield* createCompactionMarker(session.id) ++ const messages = yield* ssn.messages({ sessionID: session.id }) ++ ++ const result = yield* SessionCompaction.use.process({ ++ parentID: marker.id, ++ messages, ++ sessionID: session.id, ++ auto: false, ++ }) ++ ++ expect(result).toBe("stop") ++ const summary = (yield* ssn.messages({ sessionID: session.id })).findLast( ++ (message): message is SessionV1.WithParts & { info: SessionV1.Assistant } => ++ message.info.role === "assistant" && message.info.summary === true, ++ ) ++ expect(summary?.info.finish).toBe("error") ++ expect(summary?.info.error).toBeDefined() ++ }).pipe(withCompaction({ terminal: false, plugin: autocontinue(false) })), ++ { git: true }, ++ ) + }) + + describe("util.token.estimate", () => { +-- +2.55.0.windows.3 + diff --git a/local-fixes/patches/empty-assistant-guard.patch b/local-fixes/patches/empty-assistant-guard.patch new file mode 100644 index 000000000000..bc361e14e8a3 --- /dev/null +++ b/local-fixes/patches/empty-assistant-guard.patch @@ -0,0 +1,447 @@ +From 79542ac1e9fd1f520932fa178891fa68ace1b511 Mon Sep 17 00:00:00 2001 +From: "Mohamed R. Khalil" +Date: Tue, 6 Oct 2026 20:48:43 +0300 +Subject: [PATCH] fix(session): drop assistant messages with no provider + content (empty-assistant-guard) + +Strict providers (Kimi) reject assistant messages with no content with +400 "must not be empty", permanently wedging the session: every retry +persists another error husk and the session can never send again. + +- Emit an assistant message only when it contributes provider content: + non-empty text, a tool call, non-empty reasoning, or same-model + reasoning carrying a replayable signed/redacted payload (Anthropic + signed-thinking replay preserved; dropped after a model switch). +- Drop empty step-split blocks before conversion so a non-empty sibling + step can no longer ship an empty assistant turn. +- Guard tolerates undefined text (no .trim() crash). + +Tests: message-v2.fix5.test.ts (11 cases) + existing suite 39/39. +Upstream: anomalyco/opencode#53571 (PR #53575). +--- + local-fixes/manifest.json | 9 + + .../patches/empty-assistant-guard.patch | 124 ++++++++++ + .../src/session/message-v2.fix5.test.ts | 214 ++++++++++++++++++ + packages/opencode/src/session/message-v2.ts | 34 ++- + 4 files changed, 380 insertions(+), 1 deletion(-) + create mode 100644 local-fixes/patches/empty-assistant-guard.patch + create mode 100644 packages/opencode/src/session/message-v2.fix5.test.ts + +diff --git a/local-fixes/manifest.json b/local-fixes/manifest.json +index cd790839f6..0c87603c15 100644 +--- a/local-fixes/manifest.json ++++ b/local-fixes/manifest.json +@@ -76,6 +76,15 @@ + "compaction-model-override" + ], + "orderNote": "Must apply after compaction-model-override (rewrites the same resolution block)." ++ }, ++ { ++ "id": "empty-assistant-guard", ++ "title": "Drop assistant messages with no provider content (fix5)", ++ "commit": "ac73b30f5a", ++ "patch": "local-fixes/patches/empty-assistant-guard.patch", ++ "test": "packages/opencode/src/session/message-v2.fix5.test.ts", ++ "markers": [], ++ "orderNote": "markers: [] because the fix is a pure conversion-condition change with no new runtime strings; verification is the bundled test file (6 cases). Prevents strict providers (Kimi 400 must-not-be-empty) from wedging sessions via step-start/empty-text-only assistant messages." + } + ], + "build": { +diff --git a/local-fixes/patches/empty-assistant-guard.patch b/local-fixes/patches/empty-assistant-guard.patch +new file mode 100644 +index 0000000000..eb996e27ab +--- /dev/null ++++ b/local-fixes/patches/empty-assistant-guard.patch +@@ -0,0 +1,124 @@ ++From ac73b30f5ac97bb243704b358c005f0ec6d43557 Mon Sep 17 00:00:00 2001 ++From: "Mohamed R. Khalil" ++Date: Tue, 6 Oct 2026 20:25:11 +0300 ++Subject: [PATCH] fix(session): drop assistant messages with no provider ++ content (empty-assistant-guard) ++ ++Strict providers (Kimi) reject assistant messages carrying no content with ++400 "must not be empty", permanently wedging the session: every retry ++persists another error husk and the session can never send again. The ++conversion only guarded parts.length > 0, so messages with only step-start ++or empty-text parts shipped as empty provider messages. Now a message is ++emitted only when it contributes non-empty text, a tool call, or non-empty ++reasoning. The Anthropic signed-reasoning separator shape stays eligible ++through its reasoning parts. ++--- ++ .../src/session/message-v2.fix5.test.ts | 70 +++++++++++++++++++ ++ packages/opencode/src/session/message-v2.ts | 14 +++- ++ 2 files changed, 83 insertions(+), 1 deletion(-) ++ create mode 100644 packages/opencode/src/session/message-v2.fix5.test.ts ++ ++diff --git a/packages/opencode/src/session/message-v2.fix5.test.ts b/packages/opencode/src/session/message-v2.fix5.test.ts ++new file mode 100644 ++index 0000000000..90ddffefed ++--- /dev/null +++++ b/packages/opencode/src/session/message-v2.fix5.test.ts ++@@ -0,0 +1,70 @@ +++import { describe, expect, test } from "bun:test" +++import { toModelMessages } from "./message-v2" +++ +++// fix5-empty-assistant-guard regression tests +++const model = { +++ providerID: "kimi-for-coding", +++ id: "k3-256k", +++ api: { id: "openai-compatible", npm: "@ai-sdk/openai-compatible" }, +++} as never +++ +++function assistant(parts: any[], info: Record = {}) { +++ return { +++ info: { +++ id: "msg_" + Math.random().toString(36).slice(2, 10), +++ role: "assistant", +++ providerID: "kimi-for-coding", +++ modelID: "k3-256k", +++ ...info, +++ }, +++ parts, +++ } +++} +++function user(text: string) { +++ return { info: { id: "msg_u" + Math.random().toString(36).slice(2, 10), role: "user" }, parts: [{ type: "text", text }] } +++} +++ +++describe("fix5-empty-assistant-guard", () => { +++ test("assistant with only a step-start part is dropped", async () => { +++ const out = await toModelMessages([user("hi"), assistant([{ type: "step-start" }]), user("again")], model) +++ const roles = out.map((m: any) => m.role) +++ expect(roles).toEqual(["user", "user"]) +++ }) +++ +++ test("assistant with only an empty text part is dropped", async () => { +++ const out = await toModelMessages([user("hi"), assistant([{ type: "text", text: "" }])], model) +++ expect(out.length).toBe(1) +++ }) +++ +++ test("assistant with non-empty text is kept", async () => { +++ const out = await toModelMessages([assistant([{ type: "text", text: "hello" }])], model) +++ expect(out.length).toBe(1) +++ }) +++ +++ test("assistant with a completed tool call is kept", async () => { +++ const out = await toModelMessages([ +++ assistant([ +++ { type: "step-start" }, +++ { type: "tool", tool: "bash", callID: "c1", state: { status: "completed", input: {}, output: "ok", time: {} } }, +++ ]), +++ ], model) +++ // tool turns convert to assistant(tool-call) + tool(result) messages +++ expect(out.length).toBe(2) +++ }) +++ +++ test("signed-reasoning separator shape (non-empty reasoning + ' ' text) is kept", async () => { +++ const out = await toModelMessages([ +++ assistant([ +++ { type: "step-start" }, +++ { type: "reasoning", text: "thinking about it", metadata: { anthropic: { signature: "sig123" } } }, +++ { type: "text", text: " ", metadata: {} }, +++ ]), +++ ], model) +++ expect(out.length).toBe(1) +++ }) +++ +++ test("zero-part assistant is dropped (pre-existing behavior preserved)", async () => { +++ const out = await toModelMessages([user("hi"), assistant([])], model) +++ expect(out.length).toBe(1) +++ }) +++}) ++diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts ++index 2bd0e51715..80ef17f514 100644 ++--- a/packages/opencode/src/session/message-v2.ts +++++ b/packages/opencode/src/session/message-v2.ts ++@@ -379,7 +379,19 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( ++ }) ++ } ++ } ++- if (assistantMessage.parts.length > 0) { +++ // fix5-empty-assistant-guard: strict providers (e.g. Kimi) reject assistant +++ // messages that carry no content with 400 "must not be empty", permanently +++ // wedging the session. A non-zero part count is not sufficient: step-start +++ // and empty-text parts carry no provider content. Only emit messages that +++ // contribute non-empty text, a tool call, or non-empty reasoning. +++ // The signed-reasoning separator shape (step-start + reasoning(sig) + text(" ")) +++ // stays eligible via its reasoning parts. +++ const hasProviderContent = assistantMessage.parts.some((part) => { +++ if (part.type === "text") return part.text.trim().length > 0 +++ if (part.type === "reasoning") return part.text.trim().length > 0 +++ return part.type.startsWith("tool-") +++ }) +++ if (hasProviderContent) { ++ result.push(assistantMessage) ++ // Inject pending media as a user message for providers that don't support ++ // media (images, PDFs) in tool results ++-- ++2.55.0.windows.3 ++ +diff --git a/packages/opencode/src/session/message-v2.fix5.test.ts b/packages/opencode/src/session/message-v2.fix5.test.ts +new file mode 100644 +index 0000000000..01184931e7 +--- /dev/null ++++ b/packages/opencode/src/session/message-v2.fix5.test.ts +@@ -0,0 +1,214 @@ ++import { describe, expect, test } from "bun:test" ++import { SessionV1 } from "@opencode-ai/core/v1/session" ++import { MessageV2 } from "./message-v2" ++import type { Provider } from "@/provider/provider" ++import { SessionID, MessageID, PartID } from "./schema" ++import { ProviderV2 } from "@opencode-ai/core/provider" ++import { ModelV2 } from "@opencode-ai/core/model" ++ ++// fix5-empty-assistant-guard regression tests (typed fixtures per review F2) ++const sessionID = SessionID.make("session") ++const providerID = ProviderV2.ID.make("kimi-for-coding") ++const model: Provider.Model = { ++ id: ModelV2.ID.make("k3-256k"), ++ providerID, ++ api: { ++ id: "k3-256k", ++ url: "https://api.kimi.com/coding/v1", ++ npm: "@ai-sdk/openai-compatible", ++ }, ++ name: "Test Model", ++ capabilities: { ++ temperature: true, ++ reasoning: false, ++ attachment: false, ++ toolcall: true, ++ input: { text: true, audio: false, image: false, video: false, pdf: false }, ++ output: { text: true, audio: false, image: false, video: false, pdf: false }, ++ interleaved: false, ++ }, ++ cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, ++ limit: { context: 0, input: 0, output: 0 }, ++ status: "active", ++ options: {}, ++ headers: {}, ++ release_date: "2026-01-01", ++} ++ ++function userInfo(id: string): SessionV1.User { ++ return { ++ id, ++ sessionID, ++ role: "user", ++ time: { created: 0 }, ++ agent: "user", ++ model: { providerID, modelID: ModelV2.ID.make("test") }, ++ tools: {}, ++ mode: "", ++ } as unknown as SessionV1.User ++} ++ ++function assistantInfo( ++ id: string, ++ parentID: string, ++ meta?: { providerID: string; modelID: string }, ++): SessionV1.Assistant { ++ const infoModel = meta ?? { providerID: model.providerID, modelID: model.api.id } ++ return { ++ id, ++ sessionID, ++ role: "assistant", ++ time: { created: 0 }, ++ parentID, ++ modelID: infoModel.modelID, ++ providerID: infoModel.providerID, ++ mode: "", ++ agent: "agent", ++ path: { cwd: "/", root: "/" }, ++ cost: 0, ++ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, ++ } as unknown as SessionV1.Assistant ++} ++ ++function basePart(messageID: string, id: string) { ++ return { ++ id: PartID.make(id.startsWith("prt") ? id : `prt_${id}`), ++ sessionID, ++ messageID: MessageID.make(messageID.startsWith("msg") ? messageID : `msg_${messageID}`), ++ } ++} ++ ++let n = 0 ++function assistant(parts: object[], meta?: { providerID: string; modelID: string }): SessionV1.WithParts { ++ n++ ++ return { ++ info: assistantInfo(`msg_a${n}`, "msg_parent", meta), ++ parts: parts.map((p, i) => ({ ...basePart(`msg_a${n}`, `p${i}`), ...p })) as SessionV1.Part[], ++ } ++} ++function user(text: string): SessionV1.WithParts { ++ n++ ++ return { ++ info: userInfo(`msg_u${n}`), ++ parts: [{ ...basePart(`msg_u${n}`, "pt"), type: "text", text }] as SessionV1.Part[], ++ } ++} ++ ++function textParts(out: { role: string; parts: Array<{ type: string; text?: string }> }[]) { ++ return out.flatMap((m) => m.parts.filter((p) => p.type === "text").map((p) => (p.text ?? "").trim())) ++} ++ ++ ++function noEmptyAssistants(out: object[]) { ++ return out.every((m: object) => { ++ const mm = m as { role: string; content?: unknown } ++ if (mm.role !== "assistant") return true ++ if (typeof mm.content === "string") return mm.content.length > 0 ++ return Array.isArray(mm.content) && mm.content.length > 0 ++ }) ++} ++ ++describe("fix5-empty-assistant-guard", () => { ++ test("assistant with only a step-start part is dropped", async () => { ++ const out = await MessageV2.toModelMessages([user("hi"), assistant([{ type: "step-start" }]), user("again")], model) ++ expect(out.map((m: { role: string }) => m.role)).toEqual(["user", "user"]) ++ }) ++ ++ test("assistant with only an empty text part is dropped", async () => { ++ const out = await MessageV2.toModelMessages([user("hi"), assistant([{ type: "text", text: "" }])], model) ++ expect(out.length).toBe(1) ++ }) ++ ++ test("whitespace-only text is dropped", async () => { ++ const out = await MessageV2.toModelMessages([assistant([{ type: "text", text: " " }])], model) ++ expect(out.length).toBe(0) ++ }) ++ ++ test("assistant with non-empty text is kept", async () => { ++ const out = await MessageV2.toModelMessages([assistant([{ type: "text", text: "hello" }])], model) ++ expect(out.length).toBe(1) ++ }) ++ ++ test("assistant with a completed tool call is kept", async () => { ++ const out = await MessageV2.toModelMessages( ++ [ ++ assistant([ ++ { type: "step-start" }, ++ { type: "tool", tool: "bash", callID: "c1", state: { status: "completed", input: {}, output: "ok", time: { start: 0, end: 1 } } }, ++ ]), ++ ], ++ model, ++ ) ++ expect(out.length).toBeGreaterThanOrEqual(1) ++ }) ++ ++ test("F1: empty signed reasoning is retained (same model)", async () => { ++ const out = await MessageV2.toModelMessages( ++ [ ++ assistant([ ++ { type: "step-start" }, ++ { type: "reasoning", text: "", metadata: { anthropic: { signature: "sig123" } } }, ++ { type: "text", text: "", metadata: {} }, ++ ]), ++ ], ++ model, ++ ) ++ expect(out.length).toBe(1) ++ expect(noEmptyAssistants(out)).toBe(true) ++ expect(JSON.stringify(out)).toContain("sig123") ++ }) ++ ++ test("F1: empty redacted reasoning is retained (same model)", async () => { ++ const out = await MessageV2.toModelMessages( ++ [assistant([{ type: "reasoning", text: "", metadata: { anthropic: { redactedData: "blob" } } }])], ++ model, ++ ) ++ expect(out.length).toBe(1) ++ }) ++ ++ test("F1: signed reasoning is dropped after model switch (different model)", async () => { ++ const out = await MessageV2.toModelMessages( ++ [ ++ assistant( ++ [{ type: "step-start" }, { type: "reasoning", text: "", metadata: { anthropic: { signature: "sig" } } }, { type: "text", text: "" }], ++ { providerID: "other", modelID: "other-model" }, ++ ), ++ ], ++ model, ++ ) ++ expect(out.length).toBe(0) ++ }) ++ ++ test("F3: empty sibling step is dropped, content step kept", async () => { ++ const out = await MessageV2.toModelMessages( ++ [assistant([{ type: "step-start" }, { type: "text", text: "" }, { type: "step-start" }, { type: "text", text: "hello" }])], ++ model, ++ ) ++ expect(out.length).toBeGreaterThanOrEqual(1) ++ expect(noEmptyAssistants(out)).toBe(true) ++ expect(JSON.stringify(out)).toContain("hello") ++ }) ++ ++ test("F3: empty first, middle and last steps — only content survives", async () => { ++ const out = await MessageV2.toModelMessages( ++ [ ++ assistant([ ++ { type: "step-start" }, ++ { type: "text", text: "" }, ++ { type: "step-start" }, ++ { type: "text", text: "middle" }, ++ { type: "step-start" }, ++ { type: "text", text: " " }, ++ ]), ++ ], ++ model, ++ ) ++ expect(noEmptyAssistants(out)).toBe(true) ++ expect(JSON.stringify(out)).toContain("middle") ++ }) ++ ++ test("zero-part assistant is dropped (pre-existing behavior preserved)", async () => { ++ const out = await MessageV2.toModelMessages([user("hi"), { info: assistantInfo("msg_z", "p"), parts: [] }], model) ++ expect(out.length).toBe(1) ++ }) ++}) +diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts +index 2bd0e51715..e5fc60ea1e 100644 +--- a/packages/opencode/src/session/message-v2.ts ++++ b/packages/opencode/src/session/message-v2.ts +@@ -379,7 +379,39 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( + }) + } + } +- if (assistantMessage.parts.length > 0) { ++ // fix5-empty-assistant-guard: strict providers (e.g. Kimi) reject assistant ++ // messages that carry no content with 400 "must not be empty", permanently ++ // wedging the session. Two conditions must hold: ++ // (a) the message must contribute provider content: non-empty text, a tool ++ // call, non-empty reasoning, or same-model reasoning carrying a ++ // replayable signed/redacted payload (Anthropic adaptive thinking ++ // persists signature-bearing reasoning with empty text; dropping it ++ // would break signed-thinking replay). ++ // (b) each step-split block must contribute content: the SDK splits ++ // multi-step messages at step-start boundaries into separate provider ++ // messages, so an empty sibling step still serializes as content: "". ++ // Empty step blocks (including their step-start marker) are dropped ++ // here, before conversion. ++ const isReplayableReasoning = (part: { type: string; text?: string; providerMetadata?: any }) => { ++ if (part.type !== "reasoning") return false ++ if (typeof part.text === "string" && part.text.trim().length > 0) return true ++ if (differentModel) return false ++ const md = part.providerMetadata?.anthropic ?? part.providerMetadata ++ return Boolean(md && (md.signature != null || md.redactedData != null || md.redactedContent != null)) ++ } ++ const partHasContent = (part: { type: string; text?: string; providerMetadata?: any }) => { ++ if (part.type === "text") return typeof part.text === "string" && part.text.trim().length > 0 ++ if (part.type === "reasoning") return isReplayableReasoning(part) ++ return part.type.startsWith("tool-") ++ } ++ const stepBlocks: Array = [[]] ++ for (const part of assistantMessage.parts) { ++ if (part.type === "step-start") stepBlocks.push([]) ++ stepBlocks[stepBlocks.length - 1]!.push(part) ++ } ++ const keptParts = stepBlocks.filter((block) => block.some((part) => partHasContent(part))).flat() ++ if (keptParts.length > 0) { ++ assistantMessage.parts = keptParts + result.push(assistantMessage) + // Inject pending media as a user message for providers that don't support + // media (images, PDFs) in tool results +-- +2.55.0.windows.3 + diff --git a/local-fixes/pin-opencode.ps1 b/local-fixes/pin-opencode.ps1 new file mode 100644 index 000000000000..e347ec43a849 --- /dev/null +++ b/local-fixes/pin-opencode.ps1 @@ -0,0 +1,135 @@ +# pin-opencode.ps1 — point OpenChamber at the patched opencode binary. +# +# MECHANISM (verified 2026-09-25): OpenChamber resolves its binary at startup as +# process.env.OPENCODE_BINARY || searchPathFor('opencode') +# — the ENVIRONMENT VARIABLE is what counts. settings.json's `opencodeBinary` is +# UI-managed state: the config page reads and writes it, and OpenChamber DROPS the +# key every time it rewrites settings.json. Editing that file is ineffective on a +# cold start and unstable besides. +# +# So: the env var is the pin. The settings key is written only as a cosmetic +# best-effort (so the UI field is not blank) and only when OpenChamber is closed. +# +# Safe by construction: verifies the target is a real 1.x build before pinning, +# backs settings.json up before touching it, and refuses to write invalid JSON. +$ProgressPreference = 'SilentlyContinue' +$ErrorActionPreference = 'Stop' + +$settings = 'C:\Users\Zephyrus\.config\openchamber\settings.json' +# Versioned staging: Windows locks the running binary, so each build stages to +# opencode-patched-.exe. Pin the newest versioned build. +$candidates = Get-ChildItem 'C:\Users\Zephyrus\.local\bin\opencode-patched-*.exe' -ErrorAction SilentlyContinue | ForEach-Object { + $m = [regex]::Match($_.Name, '^opencode-patched-(\d+)\.(\d+)\.(\d+)\.exe$') + if ($m.Success) { + [PSCustomObject]@{ Path = $_.FullName; V = [version]"$($m.Groups[1].Value).$($m.Groups[2].Value).$($m.Groups[3].Value)" } + } +} +$target = ($candidates | Sort-Object V | Select-Object -Last 1).Path +if (-not $target) { + Write-Host 'MISSING: no opencode-patched-.exe in C:\Users\Zephyrus\.local\bin\' + Write-Host "Run 'bun run fixes:apply' first to build and stage a patched binary." + exit 1 +} + +Write-Host "=== pin-opencode ===" + +if (-not (Test-Path $target)) { + Write-Host "MISSING: $target" + Write-Host "Run 'bun run fixes:apply' first to build and stage the patched binary." + exit 1 +} + +# verify the target really is a patched 1.x build before anything points at it +$ver = (& $target --version 2>&1 | Out-String).Trim() +Write-Host " ok target reports version $ver" +if ($ver -notmatch '^1\.') { + Write-Host "REFUSING: $target reports '$ver', which is not an OpenCode 1.x build." + Write-Host "OpenChamber 1.24.2 requires OpenCode 1.x; v2 has no packages/opencode/ and this patch has no target there." + exit 1 +} + +# ---- primary: the environment variable OpenChamber actually reads ------------- +$prev = [Environment]::GetEnvironmentVariable('OPENCODE_BINARY', 'User') +[Environment]::SetEnvironmentVariable('OPENCODE_BINARY', $target, 'User') + +# tell Explorer and friends to refresh, so newly launched apps inherit it +try { + $sig = '[DllImport("user32.dll", SetLastError=true, CharSet=CharSet.Auto)] public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);' + $w32 = Add-Type -MemberDefinition $sig -Name W32Pin -Namespace EnvPin -PassThru + $res = [UIntPtr]::Zero + [void]$w32::SendMessageTimeout([IntPtr]0xffff, 0x1A, [UIntPtr]::Zero, 'Environment', 2, 5000, [ref]$res) +} catch { + Write-Host " warn could not broadcast the environment change: $($_.Exception.Message)" +} + +$now = [Environment]::GetEnvironmentVariable('OPENCODE_BINARY', 'User') +Write-Host "" +Write-Host " OPENCODE_BINARY (User scope)" +Write-Host " before: '$prev'" +Write-Host " now : '$now'" +if ($now -ne $target) { Write-Host "REFUSING: the environment variable did not stick."; exit 1 } +Write-Host " ok environment variable pinned <- this is the one that matters" + +# ---- secondary: the settings key, cosmetic, only while OpenChamber is closed -- +Write-Host "" +$running = Get-Process -Name OpenChamber -ErrorAction SilentlyContinue +if ($running) { + Write-Host " skip settings.json: OpenChamber is running (PID $($running.Id -join ', '))." + Write-Host " The env var alone is sufficient; that key is UI state and is dropped on rewrite anyway." +} elseif (-not (Test-Path $settings)) { + Write-Host " skip settings.json: not found at $settings" +} else { + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $backup = "$settings.bak-$stamp" + Copy-Item $settings $backup + try { + $json = Get-Content $settings -Raw | ConvertFrom-Json + $before = $json.opencodeBinary + $json.opencodeBinary = $target + $out = $json | ConvertTo-Json -Depth 100 + $null = $out | ConvertFrom-Json # validate before writing + Set-Content -Path $settings -Value $out -Encoding utf8 + $check = (Get-Content $settings -Raw | ConvertFrom-Json).opencodeBinary + if ($check -eq $target) { + Write-Host " ok settings.json opencodeBinary: '$before' -> '$check' (cosmetic; backup: $backup)" + } else { + Write-Host " warn settings.json value did not stick; restore $backup if the UI field matters." + } + } catch { + Write-Host " warn could not update settings.json: $($_.Exception.Message)" + Write-Host " Not fatal - the environment variable is the real pin." + } +} + +# ---- tertiary: converge the STABLE-NAME exe (2026-09-28, after a launch hijack) -- +# OpenChamber has been observed starting the stable-name opencode-patched.exe even +# with the env var set (settings self-rewrite and launcher env staleness both seen). +# Whatever resolution path wins, it must land on the staged build: while closed, +# refresh the stable copy; while running, at least REPORT drift loudly. +Write-Host "" +$stable = 'C:\Users\Zephyrus\.local\bin\opencode-patched.exe' +$stale = $true +if ((Test-Path $stable) -and (Test-Path $target)) { + $stale = ((Get-FileHash $stable -Algorithm SHA256).Hash -ne (Get-FileHash $target -Algorithm SHA256).Hash) +} +$busy = @(Get-Process -Name OpenChamber, opencode-patched -ErrorAction SilentlyContinue) +if ($stale -and $busy.Count -eq 0) { + Copy-Item $target $stable -Force + $okNow = ((Get-FileHash $stable -Algorithm SHA256).Hash -eq (Get-FileHash $target -Algorithm SHA256).Hash) + if ($okNow) { + Write-Host " ok refreshed stable-name exe: opencode-patched.exe == $(Split-Path $target -Leaf)" + } else { + Write-Host " warn stable-name refresh hash mismatch - investigate before trusting it" + } +} elseif ($stale) { + Write-Host " WARN stable-name exe opencode-patched.exe is STALE (hash != staged build)." + Write-Host " A launcher that ignores the env var will run the OLD build." + Write-Host " Quit OpenChamber fully and re-run this script to refresh it." +} else { + Write-Host " ok stable-name exe already matches the staged build" +} +Write-Host "" +Write-Host "Pinned. RESTART OpenChamber for it to take effect (the env var is read at startup)." +Write-Host "Verify after restart: managed-opencode\.json must record" +Write-Host " binary = $target" +Write-Host "If it records ...\resources\opencode-cli\opencode.exe, the pin did not take." diff --git a/local-fixes/verify.mjs b/local-fixes/verify.mjs new file mode 100644 index 000000000000..4553c70bc40c --- /dev/null +++ b/local-fixes/verify.mjs @@ -0,0 +1,169 @@ +#!/usr/bin/env bun +/** + * fixes:verify — cheap pre-flight. No build, no writes, ~1s. + * + * Answers one question: is the currently staged binary a trustworthy build of + * the fix, and will OpenChamber actually use it? + * + * bun run fixes:verify + */ +import { spawnSync, execFileSync } from "node:child_process" +import { createHash } from "node:crypto" +import { existsSync, readFileSync, statSync, readdirSync } from "node:fs" +import { join, dirname, basename, resolve } from "node:path" +import { fileURLToPath } from "node:url" + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..") +const manifest = JSON.parse(readFileSync(join(ROOT, "local-fixes/manifest.json"), "utf8")) +const fixes = manifest.fixes ?? (manifest.fix ? [manifest.fix] : []) +if (!fixes.length) throw new Error("manifest contains no local fixes") + +function readPinnedBinary() { + try { + return execFileSync("powershell.exe", [ + "-NoProfile", "-Command", + "[Environment]::GetEnvironmentVariable('OPENCODE_BINARY','User')", + ], { encoding: "utf8" }).trim() + } catch { + return "" + } +} + +const pinnedBinary = readPinnedBinary() + +/** Resolve the stage template to the binary OpenChamber will actually load: + * the pin target when it matches the template, else the newest versioned + * file on disk, else null. Versioned filenames exist because Windows locks + * the running binary, so each build stages to opencode-patched-.exe. */ +function resolveStagePath(template) { + if (!template.includes("{version}")) return template + const dir = dirname(template) + const tbase = basename(template).split("{version}") + const prefix = tbase[0] + const suffix = tbase[1] + const matches = (p) => basename(p).startsWith(prefix) && basename(p).endsWith(suffix) + if (pinnedBinary && matches(pinnedBinary) && existsSync(pinnedBinary)) return pinnedBinary + if (!existsSync(dir)) return null + const ranked = readdirSync(dir) + .filter((f) => f.startsWith(prefix) && f.endsWith(suffix)) + .map((f) => ({ f, v: /^(\d+)\.(\d+)\.(\d+)$/.exec(f.slice(prefix.length, suffix.length ? -suffix.length : undefined)) })) + .filter((x) => x.v) + .sort((a, b) => (a.v[1] - b.v[1]) || (a.v[2] - b.v[2]) || (a.v[3] - b.v[3])) + if (!ranked.length) return null + return join(dir, ranked[ranked.length - 1].f) +} + +let bad = 0 +const line = (mark, msg) => console.log(` ${mark} ${msg}`) +const good = (m) => line("ok ", m) +const warn = (m) => { bad++; line("FAIL", m) } +const note = (m) => line(" ", m) + +console.log(`\n=== fixes:verify — ${fixes.map((fix) => fix.id).join(", ")} ===`) + +// 1. staged binary present +console.log(`\n[1] staged binary`) +const stagePath = resolveStagePath(manifest.stage.path) +if (!stagePath || !existsSync(stagePath)) { + warn(`no staged binary for template: ${manifest.stage.path}`) + note(`run \`bun run fixes:apply\` to build and stage it`) + console.log(`\nNot trustworthy — the fix is not staged.\n`) + process.exit(1) +} +const st = statSync(stagePath) +good(`${stagePath}`) +note(`${(st.size / 1048576).toFixed(1)} MB, modified ${st.mtime.toISOString().replace("T", " ").slice(0, 19)}`) + +// 2. runtime version +console.log(`\n[2] runtime version`) +const vv = spawnSync(stagePath, ["--version"], { encoding: "utf8" }) +const reported = (vv.stdout || "").trim() +if (!reported) { + warn(`staged binary did not answer --version`) +} else if (/^0\.|dev|unknown/i.test(reported)) { + warn(`reports "${reported}" — a dev/zero version breaks free-tier models with 426`) +} else { + good(`reports ${reported}`) +} + +// 3. markers inside the binary +console.log(`\n[3] fix markers inside the staged binary`) +const hay = readFileSync(stagePath).toString("latin1") +for (const fix of fixes) { + for (const m of fix.markers) { + if (hay.includes(m)) good(`${fix.id}: present: ${m}`) + else warn(`MISSING (${fix.id}): ${m}`) + } +} + +// 3b. RUNNING binary is the staged build (2026-09-28: a launch loaded a stale +// stable-name exe while the env var was correctly set — verify what actually runs) +console.log(`\n[3b] running binary`) +try { + const managedDir = process.env.USERPROFILE + "\\.config\\openchamber\\managed-opencode" + const rec = readdirSync(managedDir).filter((f) => f.endsWith(".json")) + .map((f) => [statSync(join(managedDir, f)).mtimeMs, f]).sort().map((s) => s[1]).at(-1) + if (!rec) { + note(`no managed-opencode record found`) + } else { + const recBinary = JSON.parse(readFileSync(join(managedDir, rec), "utf8")).binary + const stagedHash = createHash("sha256").update(readFileSync(stagePath)).digest("hex") + const check = (p, label) => { + try { + const h = createHash("sha256").update(readFileSync(p)).digest("hex") + if (h === stagedHash) good(`${label} matches staged build: ${p}`) + else warn(`${label} is a DIFFERENT build than staged (stale?): ${p}`) + } catch { note(`${label} not found: ${p}`) } + } + if (recBinary) check(recBinary, `record ${rec} binary`) + const stable = process.env.USERPROFILE + "\\.local\\bin\\opencode-patched.exe" + if (existsSync(stable) && resolve(stable) !== resolve(stagePath)) check(stable, `stable-name exe`) + } +} catch (e) { note(`running-binary check skipped: ${e.message}`) } + +// 4. fix commits still in this repo +console.log(`\n[4] fix commits`) +for (const fix of fixes) { + const has = spawnSync("git", ["cat-file", "-e", `${fix.commit}^{commit}`], { cwd: ROOT, encoding: "utf8" }) + if (has.status === 0) good(`${fix.id}: ${fix.commit.slice(0, 10)} present`) + else warn(`${fix.id}: commit ${fix.commit.slice(0, 10)} not found — has it been dropped?`) +} + +// 5. does the build branch for the reported version exist? +console.log(`\n[5] build branch for the staged version`) +const branch = `${manifest.buildBranchPrefix}${reported}` +const br = spawnSync("git", ["rev-parse", "--verify", branch], { cwd: ROOT, encoding: "utf8" }) +if (br.status === 0) good(`${branch} exists`) +else note(`(no ${branch} — staged binary may predate this workflow, which is fine)`) + +// 6. will OpenChamber use it? +// The pin that COUNTS is the environment variable. OpenChamber resolves its binary at +// startup as `process.env.OPENCODE_BINARY || searchPathFor('opencode')`, and it drops +// settings.json's opencodeBinary on every settings rewrite (verified 2026-09-25). +// Checking the settings file alone reports a pin that does not exist. +console.log(`\n[6] OpenChamber pin (OPENCODE_BINARY, User scope)`) +const pinned = pinnedBinary +if (!pinned) warn(`could not read the User environment — OpenChamber may use its BUNDLED binary, not this one`) +if (pinned === stagePath) good(`OPENCODE_BINARY (User) → staged binary`) +else if (!pinned) warn(`OPENCODE_BINARY (User) is empty — OpenChamber will use its BUNDLED binary, not this one`) +else warn(`OPENCODE_BINARY (User) points at "${pinned}" — NOT the staged binary`) + +// settings.json is cosmetic UI state; report it, never trust it +const settingsFile = manifest.stage.settingsFile +if (existsSync(settingsFile)) { + try { + const s = JSON.parse(readFileSync(settingsFile, "utf8")) + const ui = s[manifest.stage.settingsKey] + if (ui === stagePath) note(`(settings.json ${manifest.stage.settingsKey} matches — cosmetic, dropped on rewrite)`) + else note(`(settings.json ${manifest.stage.settingsKey} is ${ui ? `"${ui}"` : "empty"} — cosmetic only, not read at startup)`) + } catch (e) { + note(`(could not read ${settingsFile}: ${e.message})`) + } +} + +console.log( + bad === 0 + ? `\nStaged binary is trustworthy: the fix is present and OpenChamber is pinned to it.\n` + : `\n${bad} problem(s) found — do not restart on this build.\n`, +) +process.exit(bad === 0 ? 0 : 1) diff --git a/package.json b/package.json index a3f9544410da..7cfdc218ca1a 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "random": "echo 'Random script'", "sso": "aws sso login --sso-session=opencode --no-browser", "translate:app": "bun run script/translate-app.ts", + "fixes:apply": "bun run local-fixes/apply.mjs", + "fixes:verify": "bun run local-fixes/verify.mjs", "test": "echo 'do not run tests from root' && exit 1" }, "workspaces": { diff --git a/packages/app/package.json b/packages/app/package.json index 97c98e9fc3f2..c651d9aef761 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.18.31", + "version": "1.18.32", "description": "", "type": "module", "exports": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 7e94b2310840..4c51214028dc 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/cli", - "version": "1.18.31", + "version": "1.18.32", "type": "module", "license": "MIT", "bin": { diff --git a/packages/codemode/package.json b/packages/codemode/package.json index 8a14b5769d16..0c699730d4ec 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/codemode", - "version": "1.18.31", + "version": "1.18.32", "description": "Effect-native confined code execution over schema-described tools", "private": true, "type": "module", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index b89e19a72ba2..2d66b17a913f 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.18.31", + "version": "1.18.32", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index fefbe8abfd8b..0c6b06295dd2 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.18.31", + "version": "1.18.32", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 1026754e6367..5af679c87332 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.18.31", + "version": "1.18.32", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 755102b6e74f..9bac48ba8d56 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.18.31", + "version": "1.18.32", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/console/support/package.json b/packages/console/support/package.json index 56a0099ad40f..7224fa97a1b7 100644 --- a/packages/console/support/package.json +++ b/packages/console/support/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-support", - "version": "1.18.31", + "version": "1.18.32", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index 72e834cffa6c..ad0fe5b6480b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.31", + "version": "1.18.32", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/core/src/config/compaction.ts b/packages/core/src/config/compaction.ts index 3c5960c83556..2b50225e898c 100644 --- a/packages/core/src/config/compaction.ts +++ b/packages/core/src/config/compaction.ts @@ -12,4 +12,8 @@ export class Info extends Schema.Class("ConfigV2.Compaction")({ prune: Schema.Boolean.pipe(Schema.optional), keep: Keep.pipe(Schema.optional), buffer: NonNegativeInt.pipe(Schema.optional), + // local-fix-14 (marker: compaction-model-override): pin the model used for + // compaction summaries, "provider/model" with optional ":variant" suffix. + // Unset = the session's current model (previous behavior). + model: Schema.String.pipe(Schema.optional), }) {} diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index f714633295a5..37657b2bde82 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -63,6 +63,8 @@ type Settings = { readonly auto: boolean readonly buffer: number readonly tokens: number + // local-fix-14 (marker: compaction-model-override) + readonly model: string | undefined } type Dependencies = { @@ -71,6 +73,12 @@ type Dependencies = { readonly stream: (request: LLMRequest) => Stream.Stream } readonly config: readonly Config.Entry[] + // local-fix-14 (marker: compaction-model-override): optional model-ref + // resolver (structural subset of SessionRunnerModel.Interface to avoid an + // import cycle with the runner). + readonly models?: { + readonly resolveRef?: (ref: string) => Effect.Effect + } | undefined } type Input = { @@ -129,8 +137,9 @@ const settings = (documents: readonly Config.Entry[]) => { auto: current.auto ?? result.auto, buffer: current.buffer ?? result.buffer, tokens: current.keep?.tokens ?? result.tokens, + model: current.model ?? result.model, }), - { auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS }, + { auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS, model: undefined }, ) } @@ -198,10 +207,23 @@ export const make = (dependencies: Dependencies) => { const chunks: string[] = [] let failed = false + // local-fix-14 (marker: compaction-model-override): pin the summarize to + // compaction.model when configured; any resolution failure falls back + // to the session model (previous behavior) instead of blocking compaction. + const pinnedRef = config.model + const noOverride = () => Effect.succeed(undefined) + const resolveRef: (ref: string) => Effect.Effect = + dependencies.models?.resolveRef ?? noOverride + const overrideEffect: Effect.Effect = + pinnedRef === undefined + ? Effect.succeed(undefined) + : resolveRef(pinnedRef).pipe(Effect.catchCause(() => Effect.succeed(undefined))) + const overrideModel = yield* overrideEffect + const summarizeModel = overrideModel ?? input.model const summarized = yield* dependencies.llm .stream( LLM.request({ - model: input.model, + model: summarizeModel, http: input.request.http, messages: [Message.user(summaryPrompt)], tools: [], diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 874086a06bdb..cce9f2632ac5 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -106,7 +106,9 @@ const layer = Layer.effect( const config = yield* Config.Service const snapshots = yield* Snapshot.Service const db = (yield* Database.Service).db - const compaction = SessionCompaction.make({ events, llm, config: yield* config.entries() }) + // local-fix-14 (marker: compaction-model-override): give compaction access + // to the model-ref resolver so compaction.model can be honored. + const compaction = SessionCompaction.make({ events, llm, config: yield* config.entries(), models }) const getSession = Effect.fn("SessionRunner.getSession")(function* (sessionID: SessionSchema.ID) { const session = yield* store.get(sessionID) if (!session) return yield* Effect.die(`Session not found: ${sessionID}`) diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 74e78120c20e..bf7655fd0d69 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -73,6 +73,11 @@ export type Error = export interface Interface { readonly resolve: (session: SessionSchema.Info) => Effect.Effect + // local-fix-14 (marker: compaction-model-override): resolve an arbitrary + // "provider/model[:variant]" reference (e.g. the compaction.model pin). + // Optional so existing test seams keep compiling; the production layer + // always provides it. + readonly resolveRef?: (ref: string) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/SessionRunnerModel") {} @@ -185,7 +190,27 @@ export const locationLayer = Layer.effect( const catalog = yield* Catalog.Service const integrations = yield* Integration.Service return Service.of({ - resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) { + resolveRef: Effect.fn("SessionRunnerModel.resolveRef")(function* (ref: string) { + const colon = ref.lastIndexOf(":") + const variant = colon > ref.indexOf("/") ? ref.slice(colon + 1) : undefined + const base = variant === undefined ? ref : ref.slice(0, colon) + const parsed = ModelV2.parse(base) + const available = yield* catalog.model.available() + const selected = available.find( + (candidate) => candidate.providerID === parsed.providerID && candidate.id === parsed.modelID, + ) + if (!selected) + return yield* new ModelUnavailableError({ providerID: parsed.providerID, modelID: parsed.modelID }) + const provider = yield* catalog.provider.get(selected.providerID) + const connection = yield* integrations.connection.active( + provider?.integrationID ?? Integration.ID.make(selected.providerID), + ) + const credential = connection ? yield* integrations.connection.resolve(connection) : undefined + const withRefVariant = + variant === undefined ? selected : yield* withVariant(selected, ModelV2.VariantID.make(variant)) + return yield* fromCatalogModel(withRefVariant, credential) + }), + resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session: SessionSchema.Info) { // Location plugins populate and filter the catalog asynchronously during layer startup. const defaultModel = session.model ? undefined : yield* catalog.model.default() const selected = session.model diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts index 246ddc35f5ab..5c24156ab9cf 100644 --- a/packages/core/test/session-compaction.test.ts +++ b/packages/core/test/session-compaction.test.ts @@ -45,3 +45,106 @@ test("compaction describes tool media without embedding base64", () => { expect(serialized).toBe("Image read successfully\n[Attached image/png: pixel.png]") expect(serialized).not.toContain(base64) }) + +// --- local-fix-14: compaction.model override ------------------------------------- + +import { describe, expect as expect2 } from "bun:test" +import { Effect, Stream } from "effect" +import { LLM } from "@opencode-ai/llm" +import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { SessionV2 } from "@opencode-ai/core/session" +import { it } from "./lib/effect" + +const catalogModel = (id: string, providerID: string) => + ModelV2.Info.make({ + id: ModelV2.ID.make(id), + providerID: ProviderV2.ID.make(providerID), + name: id, + api: { id: ModelV2.ID.make(id), type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://override.example/v1" }, + capabilities: { tools: false, input: ["text"], output: ["text"] }, + request: { headers: {}, body: {} }, + variants: [], + time: { released: 0 }, + cost: [], + status: "active", + enabled: true, + limit: { context: 1_000_000, output: 4096 }, + }) + +const userEntry = (text: string) => ({ + seq: 1, + message: { id: "msg_test_1", sessionID: "ses_test_1", time: 0, type: "user" as const, text, files: [] }, +}) + +describe("compaction model override (local-fix-14)", () => { + it.effect("summarizes with the configured compaction.model instead of the session model", () => + Effect.gen(function* () { + const captured: Array<{ id: string | undefined; provider: string | undefined }> = [] + const llm = { + stream: (request: unknown) => + Stream.succeed([ + { type: "text-delta", id: "cb1", text: "## Objective\n- test summary" }, + ]).pipe( + Stream.map(() => { + captured.push({ id: (request as any).model?.id, provider: (request as any).model?.provider }) + return { type: "text-delta", id: "cb1", text: "## Objective\n- test summary" } + }), + ) as unknown as typeof LLM.stream, + } + const overrideModel = yield* SessionRunnerModel.fromCatalogModel( + catalogModel("override-model", "override-provider"), + ) + const compaction = (SessionCompaction as any).make({ + events: { publish: () => Effect.void }, + llm, + config: [{ type: "document", info: { compaction: { model: "override-provider/override-model" } } }], + models: { resolveRef: () => Effect.succeed(overrideModel) }, + }) + const sessionModel = yield* SessionRunnerModel.fromCatalogModel( + catalogModel("session-model", "session-provider"), + ) + const result = yield* (compaction as any).compactAfterOverflow({ + sessionID: "ses_test_1", + entries: [userEntry("context fill ".repeat(4000))], + model: sessionModel, + request: { system: "", messages: [], tools: [] } as never, + }) + expect2(result).toBe(true) + expect2(captured.length).toBeGreaterThan(0) + expect2(captured[0]).toEqual({ id: "override-model", provider: "override-provider" }) + }), + ) + + it.effect("falls back to the session model when no compaction.model is configured", () => + Effect.gen(function* () { + const captured: Array<{ id: string | undefined; provider: string | undefined }> = [] + const llm = { + stream: (request: unknown) => + Stream.succeed([{ type: "text-delta", id: "cb1", text: "## Objective\n- fallback" }]).pipe( + Stream.map(() => { + captured.push({ id: (request as any).model?.id, provider: (request as any).model?.provider }) + return { type: "text-delta", id: "cb1", text: "## Objective\n- fallback" } + }), + ) as unknown as typeof LLM.stream, + } + const sessionModel = yield* SessionRunnerModel.fromCatalogModel( + catalogModel("session-model", "session-provider"), + ) + const compaction = (SessionCompaction as any).make({ + events: { publish: () => Effect.void }, + llm, + config: [{ type: "document", info: {} }], + }) + const result = yield* (compaction as any).compactAfterOverflow({ + sessionID: "ses_test_1", + entries: [userEntry("context fill ".repeat(4000))], + model: sessionModel, + request: { system: "", messages: [], tools: [] } as never, + }) + expect2(result).toBe(true) + expect2(captured[0]).toEqual({ id: "session-model", provider: "session-provider" }) + }), + ) +}) diff --git a/packages/desktop/package.json b/packages/desktop/package.json index f494e5341673..dac7921cf48a 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.18.31", + "version": "1.18.32", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index 1dc5d0ce6653..b68586284114 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.31", + "version": "1.18.32", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index 5a425b9feb74..04ced2b0aa90 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.31", + "version": "1.18.32", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index aea997e34e2a..8ee5257484b0 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.18.31", + "version": "1.18.32", "private": true, "type": "module", "license": "MIT", diff --git a/packages/function/package.json b/packages/function/package.json index 03348052a2fd..9b26385a8ee9 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.18.31", + "version": "1.18.32", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 1fd0b834ebab..5f6ec74b2232 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.31", + "version": "1.18.32", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", "type": "module", diff --git a/packages/llm/package.json b/packages/llm/package.json index 2b10849540a0..4acb171d2381 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.31", + "version": "1.18.32", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index b94fc9fe6b62..3a44b8729b9f 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.31", + "version": "1.18.32", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 75d6374bfa54..4fd30932b65f 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -6,6 +6,7 @@ import { SessionID, MessageID, PartID } from "./schema" import { Provider } from "@/provider/provider" import { MessageV2 } from "./message-v2" import { Token } from "@/util/token" +import { NamedError } from "@opencode-ai/core/util/error" import { SessionProcessor } from "./processor" import { Agent } from "@/agent/agent" import { Plugin } from "@/plugin" @@ -84,6 +85,45 @@ const serialize = (message: SessionV1.WithParts) => { .join("\n") } +// local-fix: compaction-pin-precedence — decide which model summarizes. An +// explicit plugin pin arrives on the marker as a model DIFFERENT from the working +// model; a core-initiated overflow marker merely echoes the working model. The pin +// wins, the configured compaction agent is the fallback, and the bare marker is the +// last resort (pre-plugin behavior). +export type SummarizerModelRef = { + readonly providerID: string + readonly modelID: string + readonly variant?: string +} + +const sameModelRef = (a?: { providerID?: string; modelID?: string }, b?: { providerID?: string; modelID?: string }) => + !!a && !!b && a.providerID === b.providerID && a.modelID === b.modelID + +export const resolveSummarizerModelRef = (input: { + readonly configured?: T + readonly marker: T + readonly working?: { readonly providerID: string; readonly modelID: string } +}): { readonly source: "pin" | "configured" | "marker"; readonly model: T } => { + if (input.marker && !sameModelRef(input.marker, input.working)) { + return { source: "pin", model: input.marker } + } + if (input.configured) { + return { source: "configured", model: input.configured } + } + return { source: "marker", model: input.marker } +} + +// The working model is the last real user turn before the marker, mirroring the +// resume-identity rule below. +export const resolveWorkingModelRef = (messages: SessionV1.WithParts[], parentID: MessageID) => { + const markerIndex = messages.findIndex((m) => m.info.id === parentID) + const history = markerIndex >= 0 ? messages.slice(0, markerIndex) : messages + const candidate = history.findLast( + (m): m is SessionV1.WithParts & { info: SessionV1.User } => + m.info.role === "user" && !m.parts.some((part) => part.type === "compaction"), + ) + return candidate?.info.model +} function summaryText(message: SessionV1.WithParts) { const text = message.parts .filter((part): part is SessionV1.TextPart => part.type === "text") @@ -329,6 +369,32 @@ const layer = Layer.effect( } const userMessage = parent.info const compactionPart = parent.parts.find((part): part is SessionV1.CompactionPart => part.type === "compaction") + // completedCompactions() only honours a summary whose parentID resolves to a user message + // carrying a compaction part. SessionPrompt now always passes the marker's own id, so a + // markerless parent means a caller broke the contract: say so loudly rather than searching + // nearby for a marker, because a guess can bind an already-consumed one and leave two + // summaries answering a single compaction. + if (!compactionPart) { + yield* Effect.logError("compaction parent carries no marker; this summary will not be readable", { + sessionID: input.sessionID, + parentID: input.parentID, + }) + } + const alreadyCompleted = input.messages.some( + (m) => + m.info.role === "assistant" && + m.info.summary === true && + m.info.finish !== undefined && + !m.info.error && + m.info.parentID === input.parentID, + ) + if (alreadyCompleted) { + yield* Effect.logWarning("compaction marker already carries a completed summary; skipping", { + sessionID: input.sessionID, + parentID: input.parentID, + }) + return "continue" + } let messages = input.messages let replay: @@ -356,11 +422,40 @@ const layer = Layer.effect( } const agent = yield* agents.get("compaction") - const model = agent.model - ? yield* provider.getModel(agent.model.providerID, agent.model.modelID).pipe(Effect.orDie) - : yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID).pipe(Effect.orDie) + // local-fix: compaction-pin-precedence — an explicit plugin pin (a marker + // model different from the working model) is the requested summarizer and + // beats the configured compaction agent; the configured agent is the fallback + // when the marker merely echoes the working model (core-initiated overflow). + const summarizerDecision = resolveSummarizerModelRef({ + configured: agent.model, + marker: userMessage.model, + working: resolveWorkingModelRef(input.messages, input.parentID), + }) + const model = yield* provider + .getModel(summarizerDecision.model.providerID, summarizerDecision.model.modelID) + .pipe(Effect.orDie) + const summarizerVariant = + summarizerDecision.source === "configured" ? agent.variant : userMessage.model.variant + // userMessage is the marker, so its model is the model this compaction was REQUESTED with + // (a plugin-supplied pin arrives exactly this way). It is not "the session model", and + // labelling it as such makes a working pin indistinguishable from a dead one in the logs. + yield* Effect.logInfo("compaction model resolved", { + source: summarizerDecision.source === "configured" ? "configured-compaction-agent" : "request-marker", + pin: summarizerDecision.source === "configured" ? "compaction-model-override" : undefined, + precedence: summarizerDecision.source, + precedenceRule: "compaction-pin-precedence", + variant: summarizerVariant, + providerID: model.providerID, + modelID: model.id, + requestedProviderID: userMessage.model.providerID, + requestedModelID: userMessage.model.modelID, + }) const cfg = yield* config.get() - const history = compactionPart && messages.at(-1)?.info.id === input.parentID ? messages.slice(0, -1) : messages + // The marker is the snapshot boundary. Everything at or after it is outside this + // compaction: the marker is bookkeeping, and a prompt that arrived after it must stay live + // rather than be summarized into history and then replayed as a live turn as well. + const markerIndex = messages.findIndex((m) => m.info.id === input.parentID) + const history = markerIndex >= 0 ? messages.slice(0, markerIndex) : messages const prior = completedCompactions(history) const hidden = new Set(prior.flatMap((item) => [item.userIndex, item.assistantIndex])) const previousSummary = prior.at(-1)?.summary @@ -397,7 +492,7 @@ const layer = Layer.effect( sessionID: input.sessionID, mode: "compaction", agent: "compaction", - variant: userMessage.model.variant, + variant: summarizerVariant, summary: true, path: { cwd: ctx.directory, @@ -458,6 +553,15 @@ const layer = Layer.effect( return "stop" } + if (result === "stop" || processor.message.error || processor.message.finish === undefined) { + processor.message.error ??= new NamedError.Unknown({ + message: "Compaction stream ended without a terminal finish", + }).toObject() + processor.message.finish = "error" + yield* session.updateMessage(processor.message) + return "stop" + } + if (compactionPart && selected.tail_start_id && compactionPart.tail_start_id !== selected.tail_start_id) { yield* session.updatePart({ ...compactionPart, @@ -465,6 +569,23 @@ const layer = Layer.effect( }) } + // The marker is a compaction request, not a turn to resume. Resume identity is the last + // real user turn BEFORE the marker - taking it from the marker instead hands the + // summarizer's model, agent and variant to the live session after compaction. + const resumeCandidate = history.findLast( + (m): m is SessionV1.WithParts & { info: SessionV1.User } => + m.info.role === "user" && !m.parts.some((p) => p.type === "compaction"), + ) + const resumeUser = resumeCandidate?.info ?? userMessage + // If a real prompt arrived after the marker it is already queued to be answered. Injecting + // a synthetic continuation as well answers one turn twice. + const markerIndexInInput = input.messages.findIndex((m) => m.info.id === input.parentID) + const postMarkerPrompt = + markerIndexInInput >= 0 && + input.messages + .slice(markerIndexInInput + 1) + .some((m) => m.info.role === "user" && !m.parts.some((p) => p.type === "compaction")) + if (result === "continue" && input.auto) { if (replay) { const original = replay.info @@ -494,23 +615,23 @@ const layer = Layer.effect( } } - if (!replay) { - const info = yield* provider.getProvider(userMessage.model.providerID) + if (!replay && !postMarkerPrompt) { + const info = yield* provider.getProvider(resumeUser.model.providerID) if ( (yield* plugin.trigger( "experimental.compaction.autocontinue", { sessionID: input.sessionID, - agent: userMessage.agent, + agent: resumeUser.agent, model: yield* provider - .getModel(userMessage.model.providerID, userMessage.model.modelID) + .getModel(resumeUser.model.providerID, resumeUser.model.modelID) .pipe(Effect.orDie), provider: { source: info.source, info, options: info.options, }, - message: userMessage, + message: resumeUser, overflow: input.overflow === true, }, { enabled: true }, @@ -521,8 +642,8 @@ const layer = Layer.effect( role: "user", sessionID: input.sessionID, time: { created: Date.now() }, - agent: userMessage.agent, - model: userMessage.model, + agent: resumeUser.agent, + model: resumeUser.model, }) const text = (input.overflow diff --git a/packages/opencode/src/session/message-v2.fix5.test.ts b/packages/opencode/src/session/message-v2.fix5.test.ts new file mode 100644 index 000000000000..01184931e741 --- /dev/null +++ b/packages/opencode/src/session/message-v2.fix5.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, test } from "bun:test" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import { MessageV2 } from "./message-v2" +import type { Provider } from "@/provider/provider" +import { SessionID, MessageID, PartID } from "./schema" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" + +// fix5-empty-assistant-guard regression tests (typed fixtures per review F2) +const sessionID = SessionID.make("session") +const providerID = ProviderV2.ID.make("kimi-for-coding") +const model: Provider.Model = { + id: ModelV2.ID.make("k3-256k"), + providerID, + api: { + id: "k3-256k", + url: "https://api.kimi.com/coding/v1", + npm: "@ai-sdk/openai-compatible", + }, + name: "Test Model", + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 0, input: 0, output: 0 }, + status: "active", + options: {}, + headers: {}, + release_date: "2026-01-01", +} + +function userInfo(id: string): SessionV1.User { + return { + id, + sessionID, + role: "user", + time: { created: 0 }, + agent: "user", + model: { providerID, modelID: ModelV2.ID.make("test") }, + tools: {}, + mode: "", + } as unknown as SessionV1.User +} + +function assistantInfo( + id: string, + parentID: string, + meta?: { providerID: string; modelID: string }, +): SessionV1.Assistant { + const infoModel = meta ?? { providerID: model.providerID, modelID: model.api.id } + return { + id, + sessionID, + role: "assistant", + time: { created: 0 }, + parentID, + modelID: infoModel.modelID, + providerID: infoModel.providerID, + mode: "", + agent: "agent", + path: { cwd: "/", root: "/" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } as unknown as SessionV1.Assistant +} + +function basePart(messageID: string, id: string) { + return { + id: PartID.make(id.startsWith("prt") ? id : `prt_${id}`), + sessionID, + messageID: MessageID.make(messageID.startsWith("msg") ? messageID : `msg_${messageID}`), + } +} + +let n = 0 +function assistant(parts: object[], meta?: { providerID: string; modelID: string }): SessionV1.WithParts { + n++ + return { + info: assistantInfo(`msg_a${n}`, "msg_parent", meta), + parts: parts.map((p, i) => ({ ...basePart(`msg_a${n}`, `p${i}`), ...p })) as SessionV1.Part[], + } +} +function user(text: string): SessionV1.WithParts { + n++ + return { + info: userInfo(`msg_u${n}`), + parts: [{ ...basePart(`msg_u${n}`, "pt"), type: "text", text }] as SessionV1.Part[], + } +} + +function textParts(out: { role: string; parts: Array<{ type: string; text?: string }> }[]) { + return out.flatMap((m) => m.parts.filter((p) => p.type === "text").map((p) => (p.text ?? "").trim())) +} + + +function noEmptyAssistants(out: object[]) { + return out.every((m: object) => { + const mm = m as { role: string; content?: unknown } + if (mm.role !== "assistant") return true + if (typeof mm.content === "string") return mm.content.length > 0 + return Array.isArray(mm.content) && mm.content.length > 0 + }) +} + +describe("fix5-empty-assistant-guard", () => { + test("assistant with only a step-start part is dropped", async () => { + const out = await MessageV2.toModelMessages([user("hi"), assistant([{ type: "step-start" }]), user("again")], model) + expect(out.map((m: { role: string }) => m.role)).toEqual(["user", "user"]) + }) + + test("assistant with only an empty text part is dropped", async () => { + const out = await MessageV2.toModelMessages([user("hi"), assistant([{ type: "text", text: "" }])], model) + expect(out.length).toBe(1) + }) + + test("whitespace-only text is dropped", async () => { + const out = await MessageV2.toModelMessages([assistant([{ type: "text", text: " " }])], model) + expect(out.length).toBe(0) + }) + + test("assistant with non-empty text is kept", async () => { + const out = await MessageV2.toModelMessages([assistant([{ type: "text", text: "hello" }])], model) + expect(out.length).toBe(1) + }) + + test("assistant with a completed tool call is kept", async () => { + const out = await MessageV2.toModelMessages( + [ + assistant([ + { type: "step-start" }, + { type: "tool", tool: "bash", callID: "c1", state: { status: "completed", input: {}, output: "ok", time: { start: 0, end: 1 } } }, + ]), + ], + model, + ) + expect(out.length).toBeGreaterThanOrEqual(1) + }) + + test("F1: empty signed reasoning is retained (same model)", async () => { + const out = await MessageV2.toModelMessages( + [ + assistant([ + { type: "step-start" }, + { type: "reasoning", text: "", metadata: { anthropic: { signature: "sig123" } } }, + { type: "text", text: "", metadata: {} }, + ]), + ], + model, + ) + expect(out.length).toBe(1) + expect(noEmptyAssistants(out)).toBe(true) + expect(JSON.stringify(out)).toContain("sig123") + }) + + test("F1: empty redacted reasoning is retained (same model)", async () => { + const out = await MessageV2.toModelMessages( + [assistant([{ type: "reasoning", text: "", metadata: { anthropic: { redactedData: "blob" } } }])], + model, + ) + expect(out.length).toBe(1) + }) + + test("F1: signed reasoning is dropped after model switch (different model)", async () => { + const out = await MessageV2.toModelMessages( + [ + assistant( + [{ type: "step-start" }, { type: "reasoning", text: "", metadata: { anthropic: { signature: "sig" } } }, { type: "text", text: "" }], + { providerID: "other", modelID: "other-model" }, + ), + ], + model, + ) + expect(out.length).toBe(0) + }) + + test("F3: empty sibling step is dropped, content step kept", async () => { + const out = await MessageV2.toModelMessages( + [assistant([{ type: "step-start" }, { type: "text", text: "" }, { type: "step-start" }, { type: "text", text: "hello" }])], + model, + ) + expect(out.length).toBeGreaterThanOrEqual(1) + expect(noEmptyAssistants(out)).toBe(true) + expect(JSON.stringify(out)).toContain("hello") + }) + + test("F3: empty first, middle and last steps — only content survives", async () => { + const out = await MessageV2.toModelMessages( + [ + assistant([ + { type: "step-start" }, + { type: "text", text: "" }, + { type: "step-start" }, + { type: "text", text: "middle" }, + { type: "step-start" }, + { type: "text", text: " " }, + ]), + ], + model, + ) + expect(noEmptyAssistants(out)).toBe(true) + expect(JSON.stringify(out)).toContain("middle") + }) + + test("zero-part assistant is dropped (pre-existing behavior preserved)", async () => { + const out = await MessageV2.toModelMessages([user("hi"), { info: assistantInfo("msg_z", "p"), parts: [] }], model) + expect(out.length).toBe(1) + }) +}) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 2bd0e5171589..e5fc60ea1e3a 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -379,7 +379,39 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( }) } } - if (assistantMessage.parts.length > 0) { + // fix5-empty-assistant-guard: strict providers (e.g. Kimi) reject assistant + // messages that carry no content with 400 "must not be empty", permanently + // wedging the session. Two conditions must hold: + // (a) the message must contribute provider content: non-empty text, a tool + // call, non-empty reasoning, or same-model reasoning carrying a + // replayable signed/redacted payload (Anthropic adaptive thinking + // persists signature-bearing reasoning with empty text; dropping it + // would break signed-thinking replay). + // (b) each step-split block must contribute content: the SDK splits + // multi-step messages at step-start boundaries into separate provider + // messages, so an empty sibling step still serializes as content: "". + // Empty step blocks (including their step-start marker) are dropped + // here, before conversion. + const isReplayableReasoning = (part: { type: string; text?: string; providerMetadata?: any }) => { + if (part.type !== "reasoning") return false + if (typeof part.text === "string" && part.text.trim().length > 0) return true + if (differentModel) return false + const md = part.providerMetadata?.anthropic ?? part.providerMetadata + return Boolean(md && (md.signature != null || md.redactedData != null || md.redactedContent != null)) + } + const partHasContent = (part: { type: string; text?: string; providerMetadata?: any }) => { + if (part.type === "text") return typeof part.text === "string" && part.text.trim().length > 0 + if (part.type === "reasoning") return isReplayableReasoning(part) + return part.type.startsWith("tool-") + } + const stepBlocks: Array = [[]] + for (const part of assistantMessage.parts) { + if (part.type === "step-start") stepBlocks.push([]) + stepBlocks[stepBlocks.length - 1]!.push(part) + } + const keptParts = stepBlocks.filter((block) => block.some((part) => partHasContent(part))).flat() + if (keptParts.length > 0) { + assistantMessage.parts = keptParts result.push(assistantMessage) // Inject pending media as a user message for providers that don't support // media (images, PDFs) in tool results diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0f85d44f209b..e0ab525b5e52 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1149,7 +1149,12 @@ const layer = Layer.effect( if (task?.type === "compaction") { const result = yield* compaction.process({ messages: msgs, - parentID: lastUser.id, + // The task IS the compaction part, and every part carries its own messageID. + // Using lastUser.id instead means a prompt that arrives between the marker being + // written and this running becomes the summary's parent, which makes the summary + // unreadable to completedCompactions() and discards the model the compaction was + // requested with. + parentID: task.messageID, sessionID, auto: task.auto, overflow: task.overflow, diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index c76dd98b8614..c66e5a46907e 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -189,6 +189,7 @@ function createCompactionMarker(sessionID: SessionID) { type: "compaction", auto: false, }) + return msg }), ) } @@ -196,8 +197,10 @@ function createCompactionMarker(sessionID: SessionID) { function fake( input: Parameters[0], result: "continue" | "compact", + terminal = true, ) { const msg = input.assistantMessage + if (result === "continue" && terminal) msg.finish = "end_turn" return { get message() { return msg @@ -208,11 +211,11 @@ function fake( } satisfies SessionProcessorModule.SessionProcessor.Handle } -function processorLayer(result: "continue" | "compact") { +function processorLayer(result: "continue" | "compact", terminal = true) { return Layer.succeed( SessionProcessorModule.SessionProcessor.Service, SessionProcessorModule.SessionProcessor.Service.of({ - create: Effect.fn("TestSessionProcessor.create")((input) => Effect.succeed(fake(input, result))), + create: Effect.fn("TestSessionProcessor.create")((input) => Effect.succeed(fake(input, result, terminal))), }), ) } @@ -222,6 +225,15 @@ function cfg(compaction?: ConfigV1.Info["compaction"]) { return Layer.succeed(Config.Service, TestConfig.make({ get: () => Effect.succeed({ ...base, compaction }) })) } +// local-fix: compaction-model-override — config layer that pins the compaction agent +function cfgCompactionAgent(agent: NonNullable[string]) { + const base = Schema.decodeUnknownSync(ConfigV1.Info)({}) as ConfigV1.Info + return Layer.succeed( + Config.Service, + TestConfig.make({ get: () => Effect.succeed({ ...base, agent: { compaction: agent } }) }), + ) +} + const defaultProvider = wide() const compactionTestNode = LayerNode.group([ SessionCompaction.node, @@ -246,6 +258,7 @@ const itCompaction = testEffect(compactionEnv) type CompactionProcessOptions = { result?: "continue" | "compact" + terminal?: boolean llm?: Layer.Layer plugin?: Layer.Layer provider?: ReturnType @@ -265,7 +278,7 @@ function compactionProcessLayer(options?: CompactionProcessOptions) { if (!options?.llm) { return AppNodeBuilder.build(compactionTestNode, [ ...replacements, - [SessionProcessorModule.SessionProcessor.node, processorLayer(options?.result ?? "continue")], + [SessionProcessorModule.SessionProcessor.node, processorLayer(options?.result ?? "continue", options?.terminal ?? true)], ...(options?.plugin ? ([[Plugin.node, options.plugin]] as const) : []), ...(options?.config ? ([[Config.node, options.config]] as const) : []), ]) @@ -842,7 +855,7 @@ describe("session.compaction.process", () => { }), ) - it.instance( + itCompaction.instance( "publishes compacted event on continue", Effect.gen(function* () { const events = yield* EventV2Bridge.Service @@ -850,14 +863,12 @@ describe("session.compaction.process", () => { const session = yield* ssn.create({}) const msg = yield* createUserMessage(session.id, "hello") const msgs = yield* ssn.messages({ sessionID: session.id }) - const done = yield* Deferred.make() const seen: string[] = [] const unsub = yield* events.listen((evt) => { seen.push(evt.type) if (evt.type !== SessionCompaction.Event.Compacted.type) return Effect.void if ((evt.data as typeof SessionCompaction.Event.Compacted.data.Type).sessionID !== session.id) return Effect.void - Deferred.doneUnsafe(done, Effect.void) return Effect.void }) yield* Effect.addFinalizer(() => unsub) @@ -869,11 +880,10 @@ describe("session.compaction.process", () => { auto: false, }) - yield* Deferred.await(done).pipe(Effect.timeout("500 millis")) expect(result).toBe("continue") expect(seen).toContain(SessionCompaction.Event.Compacted.type) expect(seen.filter((type) => type.startsWith("session.next."))).toEqual([]) - }), + }).pipe(withCompaction({ plugin: autocontinue(false) })), ) itCompaction.instance( @@ -901,10 +911,10 @@ describe("session.compaction.process", () => { expect(summary.info.finish).toBe("error") expect(JSON.stringify(summary.info.error)).toContain("Session too large to compact") } - }).pipe(withCompaction({ result: "compact" })), + }).pipe(withCompaction({ result: "compact", plugin: autocontinue(false) })), ) - it.instance( + itCompaction.instance( "adds synthetic continue prompt when auto is enabled", Effect.gen(function* () { const ssn = yield* SessionNs.Service @@ -932,7 +942,7 @@ describe("session.compaction.process", () => { if (last?.parts[0]?.type === "text") { expect(last.parts[0].text).toContain("Continue if you have next steps") } - }), + }).pipe(withCompaction({ plugin: autocontinue(true) })), ) itCompaction.instance( @@ -1051,6 +1061,47 @@ describe("session.compaction.process", () => { { git: true }, ) + itCompaction.instance( + "compaction agent model and variant pin the summarize (local-fix: compaction-model-override)", + () => { + const stub = llm() + let capturedModel = "" + stub.push( + reply("summary", (input) => { + capturedModel = `${(input.model as any)?.providerID ?? "?"}/${(input.model as any)?.id ?? "?"}` + }), + ) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "pin me") + yield* createSummaryCompaction(session.id) + + const msgs = yield* ssn.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) + + const summary = (yield* ssn.messages({ sessionID: session.id })).find( + (m) => m.info.role === "assistant" && m.info.summary, + ) + expect(summary).toBeTruthy() + expect((summary!.info as any).agent).toBe("compaction") + expect((summary!.info as any).variant).toBe("low") + expect(capturedModel).toContain("test/test-model") + }).pipe( + withCompaction({ + llm: stub.llmLayer, + config: cfgCompactionAgent({ + model: "test/test-model", + variant: "low", + }), + }), + ) + }, + { git: true }, + ) + itCompaction.instance( "retains a split turn suffix when a later message fits the preserve token budget", () => { @@ -1660,6 +1711,189 @@ describe("session.compaction.process", () => { expect(part?.tail_start_id).toBe(keep.id) }).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 500 }) })), ) + + // --- mid-compaction prompt race ------------------------------------------------------- + // A prompt landing between the marker and the summarize used to become the summary's parent, + // which made the summary unreadable, discarded the requested model, and replayed the whole + // history on every later turn. + + itCompaction.instance( + "anchors the summary to the marker and leaves a mid-compaction prompt out of the summary", + () => { + const stub = llm() + let captured = "" + stub.push(reply("summary", (input) => (captured = JSON.stringify(input.messages)))) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "earlier real work") + yield* createCompactionMarker(session.id) + + const afterMarker = yield* ssn.messages({ sessionID: session.id }) + const markerID = afterMarker.at(-1)!.info.id + + // the prompt that arrives while the summarize is in flight + const typed = yield* createUserMessage(session.id, "WAIT-LOOK-AT-THIS") + + const msgs = yield* ssn.messages({ sessionID: session.id }) + // SessionPrompt now passes the compaction part's own messageID, not the last user id + yield* SessionCompaction.use.process({ + parentID: markerID, + messages: msgs, + sessionID: session.id, + auto: false, + }) + + const after = yield* ssn.messages({ sessionID: session.id }) + const summaryMsg = after.findLast((m) => m.info.role === "assistant" && m.info.summary === true) + expect(summaryMsg).toBeTruthy() + const summaryInfo = summaryMsg!.info as SessionV1.Assistant + + // 1. readable by completedCompactions() + expect(summaryInfo.parentID).toBe(markerID) + // 2. the mid-compaction prompt is NOT swallowed into the summarized history + expect(captured).toContain("earlier real work") + expect(captured).not.toContain("WAIT-LOOK-AT-THIS") + // 3. and it is still present as a live message + expect(after.some((m) => m.info.id === typed.id)).toBe(true) + }).pipe(withCompaction({ llm: stub.llmLayer })) + }, + { git: true }, + ) + + itCompaction.instance( + "never re-anchors a markerless parent onto an earlier, already-used marker", + () => { + const stub = llm() + stub.push(reply("second summary")) + return Effect.gen(function* () { + const test = yield* TestInstance + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "first") + yield* createCompactionMarker(session.id) + const afterMarker = yield* ssn.messages({ sessionID: session.id }) + const firstMarkerID = afterMarker.at(-1)!.info.id + yield* createSummaryAssistantMessage(session.id, firstMarkerID, test.directory, "first summary") + + // a later compaction whose parent is a plain prompt, with a spent marker behind it + const plain = yield* createUserMessage(session.id, "a plain prompt, no marker") + const msgs = yield* ssn.messages({ sessionID: session.id }) + yield* SessionCompaction.use.process({ + parentID: plain.id, + messages: msgs, + sessionID: session.id, + auto: false, + }) + + const after = yield* ssn.messages({ sessionID: session.id }) + const summaries = after.filter((m) => m.info.role === "assistant" && m.info.summary === true) + expect(summaries.length).toBe(2) + // Searching backwards for "the nearest marker" would bind the spent one and leave two + // summaries answering a single compaction. The contract is honesty about the parent we + // were given, not a guess that corrupts completedCompactions(). + const second = summaries.at(-1)!.info as SessionV1.Assistant + expect(second.parentID).toBe(plain.id) + expect(second.parentID).not.toBe(firstMarkerID) + }).pipe(withCompaction({ llm: stub.llmLayer })) + }, + { git: true }, + ) + + itCompaction.instance( + "does not consume a marker that already carries a completed summary", + () => { + const stub = llm() + stub.push(reply("second summary")) + return Effect.gen(function* () { + const test = yield* TestInstance + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "work") + yield* createCompactionMarker(session.id) + const afterMarker = yield* ssn.messages({ sessionID: session.id }) + const markerID = afterMarker.at(-1)!.info.id + yield* createSummaryAssistantMessage(session.id, markerID, test.directory, "first summary") + + const msgs = yield* ssn.messages({ sessionID: session.id }) + const result = yield* SessionCompaction.use.process({ + parentID: markerID, + messages: msgs, + sessionID: session.id, + auto: false, + }) + + expect(result).toBe("continue") + const after = yield* ssn.messages({ sessionID: session.id }) + const summaries = after.filter((m) => m.info.role === "assistant" && m.info.summary === true) + // two summaries against one marker give completedCompactions() duplicate userIndex rows + expect(summaries.length).toBe(1) + }).pipe(withCompaction({ llm: stub.llmLayer })) + }, + { git: true }, + ) + + itCompaction.instance( + "suppresses the synthetic continuation when a real prompt is already waiting", + () => { + const stub = llm() + stub.push(reply("summary")) + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "work") + yield* createCompactionMarker(session.id) + const afterMarker = yield* ssn.messages({ sessionID: session.id }) + const markerID = afterMarker.at(-1)!.info.id + const typed = yield* createUserMessage(session.id, "the real follow-up") + + const msgs = yield* ssn.messages({ sessionID: session.id }) + yield* SessionCompaction.use.process({ + parentID: markerID, + messages: msgs, + sessionID: session.id, + auto: true, + }) + + const after = yield* ssn.messages({ sessionID: session.id }) + const synthetic = after.filter((m) => + m.parts.some((part) => part.type === "text" && part.synthetic === true), + ) + // the waiting prompt answers this turn; a synthetic continue would answer it twice + expect(synthetic.length).toBe(0) + expect(after.some((m) => m.info.id === typed.id)).toBe(true) + }).pipe(withCompaction({ llm: stub.llmLayer })) + }, + { git: true }, + ) + + itCompaction.instance( + "stops when the summary stream ends without a terminal finish", + () => + Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const session = yield* ssn.create({}) + yield* createUserMessage(session.id, "work before compaction") + const marker = yield* createCompactionMarker(session.id) + const messages = yield* ssn.messages({ sessionID: session.id }) + + const result = yield* SessionCompaction.use.process({ + parentID: marker.id, + messages, + sessionID: session.id, + auto: false, + }) + + expect(result).toBe("stop") + const summary = (yield* ssn.messages({ sessionID: session.id })).findLast( + (message): message is SessionV1.WithParts & { info: SessionV1.Assistant } => + message.info.role === "assistant" && message.info.summary === true, + ) + expect(summary?.info.finish).toBe("error") + expect(summary?.info.error).toBeDefined() + }).pipe(withCompaction({ terminal: false, plugin: autocontinue(false) })), + { git: true }, + ) }) describe("util.token.estimate", () => { @@ -1973,3 +2207,62 @@ describe("SessionNs.getUsage", () => { expect(result.tokens.cache.write).toBe(300) }) }) + + +// --- local-fix: compaction-pin-precedence ------------------------------------------------ +// An explicit plugin pin on the marker (a model different from the working model) +// is the requested summarizer and beats the configured compaction agent. The +// configured agent is the fallback when the marker merely echoes the working +// model (core-initiated overflow) or is absent. + +describe("resolveSummarizerModelRef (compaction-pin-precedence)", () => { + const ref = (providerID: string, modelID: string, variant?: string) => ({ providerID, modelID, ...(variant ? { variant } : {}) }) + const resolve = SessionCompaction.resolveSummarizerModelRef + + test("plugin pin on the marker beats the configured compaction agent", () => { + const decision = resolve({ + configured: ref("openai", "gpt-6-luna"), + marker: ref("zai-coding-plan", "glm-5.3-flash"), + working: ref("kimi-for-coding", "k3-256k"), + }) + expect(decision.source).toBe("pin") + expect(decision.model).toEqual(ref("zai-coding-plan", "glm-5.3-flash")) + }) + + test("marker echoing the working model falls back to the configured agent", () => { + const decision = resolve({ + configured: ref("openai", "gpt-6-luna"), + marker: ref("kimi-for-coding", "k3-256k"), + working: ref("kimi-for-coding", "k3-256k"), + }) + expect(decision.source).toBe("configured") + expect(decision.model).toEqual(ref("openai", "gpt-6-luna")) + }) + + test("marker echoing the working model with no configured agent keeps the marker", () => { + const decision = resolve({ + marker: ref("kimi-for-coding", "k3-256k"), + working: ref("kimi-for-coding", "k3-256k"), + }) + expect(decision.source).toBe("marker") + expect(decision.model).toEqual(ref("kimi-for-coding", "k3-256k")) + }) + + test("marker with no resolvable working model is treated as an explicit pin", () => { + const decision = resolve({ + configured: ref("openai", "gpt-6-luna"), + marker: ref("zai-coding-plan", "glm-5.3-flash"), + }) + expect(decision.source).toBe("pin") + expect(decision.model).toEqual(ref("zai-coding-plan", "glm-5.3-flash")) + }) + + test("pin carries the marker variant; configured carries the agent variant slot", () => { + const pin = resolve({ + configured: ref("openai", "gpt-6-luna"), + marker: { providerID: "zai-coding-plan", modelID: "glm-5.3-flash", variant: "turbo" }, + working: ref("kimi-for-coding", "k3-256k"), + }) + expect(pin.model).toEqual({ providerID: "zai-coding-plan", modelID: "glm-5.3-flash", variant: "turbo" }) + }) +}) \ No newline at end of file diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 09c1210988cb..efb79b8e94f1 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.18.31", + "version": "1.18.32", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index a471612ecaf2..4662037d9b3c 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.18.31", + "version": "1.18.32", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index c99bd4ac87d8..69cc9580f0c3 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/server", - "version": "1.18.31", + "version": "1.18.32", "private": true, "type": "module", "license": "MIT", diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index aaba6a52d87c..2164064facf2 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/session-ui", - "version": "1.18.31", + "version": "1.18.32", "private": true, "type": "module", "license": "MIT", diff --git a/packages/slack/package.json b/packages/slack/package.json index 86fc0d763af0..e106fb7e9707 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.18.31", + "version": "1.18.32", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index bd1064c18c38..27b6957799c4 100644 --- a/packages/stats/app/package.json +++ b/packages/stats/app/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-app", - "version": "1.18.31", + "version": "1.18.32", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index 3bfb642ea2f5..01b409896139 100644 --- a/packages/stats/core/package.json +++ b/packages/stats/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-core", - "version": "1.18.31", + "version": "1.18.32", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json index 26efcc3aae4b..8059a79d1c2d 100644 --- a/packages/stats/server/package.json +++ b/packages/stats/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-server", - "version": "1.18.31", + "version": "1.18.32", "private": true, "type": "module", "license": "MIT", diff --git a/packages/tui/package.json b/packages/tui/package.json index bc8c1181e9e5..c62751124388 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/tui", - "version": "1.18.31", + "version": "1.18.32", "private": true, "type": "module", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index 02c1573f0d93..49d6d7d130f2 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.18.31", + "version": "1.18.32", "type": "module", "license": "MIT", "repository": { diff --git a/packages/web/package.json b/packages/web/package.json index d5716400d33c..8084bd39cd0e 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.18.31", + "version": "1.18.32", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 779435fece56..b977dcdc2366 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.18.31", + "version": "1.18.32", "publisher": "sst-dev", "repository": { "type": "git", diff --git a/specs/corpus-incident-redesign-design.md b/specs/corpus-incident-redesign-design.md new file mode 100644 index 000000000000..377f41c240b9 --- /dev/null +++ b/specs/corpus-incident-redesign-design.md @@ -0,0 +1,69 @@ +# Corpus Incident Redesign + +## Goal + +Publish every split incident as one stable canon record, preserve the established incident-family taxonomy, and make future additions collision-safe and index-consistent. All implementation tests use temporary corpus roots; no Hindsight write, merge, push, or live corpus migration is part of this work. + +## Verified Context + +The unmerged `redesign/per-incident-split` branch at `65ea7b6` originally had 116 incident files and links. Before implementation, three further X-family incidents were added, so the supplied baseline and current source contain 119 linked files. The conservation check covered 3,165 lines with none missing. `build_bundle.mjs` currently reads only root-level Markdown. A naive recursive reuse of its current ID logic would produce `??:overview` IDs for incident files without `##` headings. + +The existing ID letters carry meaning. Current and historical families include `A`, `C`, `D`, `G`, `K`, `L`, `M`, `O`, `P`, `R`, `S`, `U`, and `X`; `C-01` and `O-01` are retained legacy references for Context Mode and the Orca hook. A split file's filename is authoritative: `X-111-...md` intentionally retains an older `X-92` heading. + +## Design + +### Bundle Generation + +- Extract pure bundle-building logic that accepts an injected corpus root; importing it must not read or write the live corpus. Preserve root-document sectioning and IDs. +- Read root Markdown and sorted `incidents/*.md`. Validate incident filenames and reject duplicate incident IDs even when their slugs differ. +- Emit exactly one incident record per file with stable ID `env-canon:v1:15:`, logical document number `15`, `surface: incidents`, and `metadata.source_file` equal to the normalized relative path `incidents/`. +- Make the corpus digest deterministic across creation order and include normalized paths, lengths, and content with unambiguous separators. Renames and content edits must each change it. +- Check that the ledger's linked incident IDs match the incident files. A legacy unmarked index can be checked before its first generated rewrite. + +### Family-Preserving Addition + +- `corpus-add.mjs` requires `--prefix`, `--title`, and `--body-file`; tests and maintenance commands accept an injected `--corpus` root. Known prefixes are `A`, `C`, `D`, `G`, `K`, `L`, `M`, `O`, `P`, `R`, `S`, `U`, and `X`. Unknown prefixes are rejected until the family catalog is deliberately extended. +- Allocate within the requested family, using at least two numeric digits (`G-07`, `G-100`) and accounting for IDs in incident filenames, reservations, and retained legacy ledger text (`C-01` must not be reissued). Existing gaps do not override the family's maximum. +- `X` is reserved for explicitly cross-cutting issues and requires `--cross-cutting`; the flag is rejected for other prefixes. +- Serialize by ID, not slug: create the reservation for `G-07` exclusively with `openSync(..., 'wx')`, so titles producing different slugs cannot both claim `G-07`. +- Validate title and body before reserving. Build the canonical `### — ` heading, normalize newlines, and prepare the complete file before exposing it at its final path. Publish it atomically with a same-volume hard link; never overwrite an existing incident. +- Create the narrow `incidents/.ids/.gitignore` (`*` and `!.gitignore`) so reservation tombstones and staging files are excluded from the corpus autocommit. Preserve an existing ignore file rather than overwriting it. +- Update the generated ledger index under an exclusive index-write lock and re-list/re-render until stable. Interrupted writes must leave no partial `.md` incident; stale reservations may burn an ID but cannot cause reuse. + +### Generated Index + +- Add `corpus-index.mjs` with `--check`, `--write`, and `--migrate` operations. `corpus-add.mjs` refreshes the index after creating an incident. `--write` requires an already marked generated region; the existing unmarked split index needs the one-time `--migrate` operation before the first add. +- Bound the generated link list with explicit begin/end markers. Generated H2 headings are count-free so each new entry does not change the Hindsight record ID. Counts belong in the region body. +- Preserve the retained non-incident tail byte-for-byte. `--migrate` converts the current unmarked split index; later `--write` operations are idempotent. +- Bundle validation detects missing, extra, duplicate, or malformed incident links. The filename ID is authoritative; enforce matching heading IDs for newly created entries only. + +### `mk-canon-update` Workflow + +- Replace the implied manual edit of `15-INCIDENT-AND-WORKAROUND-LEDGER.md` with a required `corpus-add.mjs --prefix ... --title ... --body-file ...` workflow. +- Explain the established prefix families, reserve `X` for cross-cutting items with the explicit flag, and direct agents to inspect existing entries before choosing a family. +- State that incident files are authoritative; the ledger's generated region is not hand-edited. Amend the incident file itself, then use `corpus-index.mjs --write` to repair/regenerate the index. +- Run the bundle tests and `corpus-index.mjs --check` before the existing bundle, scanner, retrieval, and publication gates. + +## Tests and Smoke Gate + +Use Node's built-in `node:test` and `mkdtempSync` fixtures. Tests must not read or write the live corpus, touch default `bundle.json`, call Hindsight, publish, or use network services. Write each test before its implementation and verify the expected RED failure. + +Cover at least: + +- Existing root-document IDs and hashes stay unchanged; one entire record per incident; all filenames map to unique `15`/`incidents` records with correct source paths, hashes, and index entries. +- Bad filenames; duplicate IDs with different slugs; filename/old-heading disagreement; empty files; missing/extra index links; unmarked-to-marked index migration; byte-exact retained tail; idempotent repeated index writes. +- Missing, lowercase, malformed, and unknown prefixes; `X` without `--cross-cutting`; rejection before reserving; all known families; no cross-family sequence bleed; `G-06 -> G-07`, `G-99 -> G-100`, `X-111 -> X-112`, and legacy-only `C-01 -> C-02` / `O-01 -> O-02`. +- `--cross-cutting` with a non-X prefix is rejected. A temporary Git fixture confirms reservation and staging files are ignored while `.ids/.gitignore` remains trackable. +- Same-ID/different-slug concurrent contenders, stale reservations, an existing reservation, an occupied final path, and eight concurrent adds with distinct IDs and matching bodies/index links. +- Empty and whitespace-only titles/bodies, title punctuation/path separators, Unicode-only and maximum-length titles, CRLF body input, injected heading handling, and missing body-file paths. +- Digest repeatability across creation orders, change on rename-only and content-only changes, and boundary separation (`a="x", b="yz"` differs from `a="xy", b="z"`). + +Before claiming completion, run the full fixture suite and a read-only integration smoke build against the current 119-file split with output directed to a temporary path. Confirm every current incident record exactly once, unique IDs, exact root-record compatibility, correct digest behavior, unchanged default `bundle.json` hash, `scan_secrets.mjs <temp-bundle>` PASS, `rehearse_retrieval.mjs <temp-bundle>` PASS, and `corpus-index.mjs --check` PASS. Do not publish. + +For the skill edit, run a baseline pressure scenario with at least three combined pressures before editing, then run the same scenario against the updated skill and confirm it chooses the explicit family and `corpus-add` path. No implementation-success claim before both the code smoke gate and skill pressure gate pass. + +## Migration and Safety Boundary + +`sync.mjs` reports old bundle-absent Hindsight IDs as orphans and does not delete them. This work does not publish, delete, or migrate Hindsight records. The live corpus remains on its unmerged branch and is not rewritten by tests; one-time live index migration, branch switching, merge, push, and commit remain separate owner-controlled operations. + +Other stale duplicate-ID instructions in `CLAUDE.md`, `AGENTS.md`, and `memory-routing/SKILL.md` are outside this change and require separate approval.