From 545f51d26cc39a907d2867492d498d9607ea5fa4 Mon Sep 17 00:00:00 2001 From: opencode Date: Mon, 21 Sep 2026 22:51:07 +0000 Subject: [PATCH 01/24] release: v1.18.32 --- bun.lock | 56 ++++++++++----------- packages/app/package.json | 2 +- packages/cli/package.json | 2 +- packages/codemode/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/console/support/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/effect-drizzle-sqlite/package.json | 2 +- packages/effect-sqlite-node/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/server/package.json | 2 +- packages/session-ui/package.json | 2 +- packages/slack/package.json | 2 +- packages/stats/app/package.json | 2 +- packages/stats/core/package.json | 2 +- packages/stats/server/package.json | 2 +- packages/tui/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 30 files changed, 57 insertions(+), 57 deletions(-) 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/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/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/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", From b855924e87db342c69a3a4c26beefc77f4cdc858 Mon Sep 17 00:00:00 2001 From: "Mohamed R. Khalil" Date: Fri, 25 Sep 2026 13:23:47 +0300 Subject: [PATCH 02/24] 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 75d6374bfa54..7affc3749859 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 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..ab084a22daec 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", () => { From 1212c76b6483f723cac17c4e97a18cadb3a8f3b6 Mon Sep 17 00:00:00 2001 From: "Mohamed R. Khalil" Date: Fri, 25 Sep 2026 13:26:34 +0300 Subject: [PATCH 03/24] chore(local-fixes): ledger the compaction-anchor fix with an upgrade pipeline The fix was uncommitted working-tree changes on fix/compaction-anchor, so any checkout would have silently destroyed it. Adds: local-fixes/manifest.json the fix, its markers, build and stage config local-fixes/apply.mjs upgrades to a newer v1.x tag and re-applies the fix local-fixes/verify.mjs cheap pre-flight on the staged binary local-fixes/patches/compaction-anchor.patch fallback when a cherry-pick conflicts fixes:apply / fixes:verify npm scripts The fix commit is immutable: every upgrade builds on a fresh build/ branch cut from the target tag, so an upgrade can never rewrite or lose the fix, and a failed upgrade restores the original branch untouched. --- local-fixes/apply.mjs | 256 ++++++++++++++ local-fixes/manifest.json | 42 +++ local-fixes/patches/compaction-anchor.patch | 358 ++++++++++++++++++++ local-fixes/verify.mjs | 95 ++++++ package.json | 2 + 5 files changed, 753 insertions(+) create mode 100644 local-fixes/apply.mjs create mode 100644 local-fixes/manifest.json create mode 100644 local-fixes/patches/compaction-anchor.patch create mode 100644 local-fixes/verify.mjs diff --git a/local-fixes/apply.mjs b/local-fixes/apply.mjs new file mode 100644 index 000000000000..21537c381219 --- /dev/null +++ b/local-fixes/apply.mjs @@ -0,0 +1,256 @@ +#!/usr/bin/env bun +/** + * fixes:apply — upgrade the patched opencode binary to a newer upstream tag, + * re-applying our compaction-anchor fix, then verify it 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 + * + * The fix commit is IMMUTABLE. Every run creates a fresh build/ + * branch from the target tag and cherry-picks the fix onto it, so an upgrade + * can never rewrite or lose the fix, 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) +} + +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 ${manifest.fix.id} ===`) +const fix = manifest.fix +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) { + // tag may not be fetched yet if an explicit arg 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}`) +if (git(["cat-file", "-e", `${fix.commit}^{commit}`]).code !== 0) { + fail(`fix commit is missing from this repo: ${fix.commit}`) +} +ok(`fix 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 the fix +step++ +console.log(`\n[${step}] re-apply the fix`) +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 the fix at ${tag}.\n` + + ` Nothing was changed — you are back on ${startBranch}.\n` + + ` Rebase ${fix.commit.slice(0, 10)} by hand, then update manifest.fix.commit.`, + ) +} + +// ---------------------------------------------------------------- 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 the fix's tests +step++ +console.log(`\n[${step}] run ${fix.tests}`) +const t = spawnSync("bun", ["test", `test/${fix.tests.replace(/^test\//, "")}`, "--timeout", "30000"], { + cwd: buildDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], +}) +const tOut = `${t.stdout || ""}\n${t.stderr || ""}` +const summary = (tOut.match(/^\s*\d+ pass.*$/m) || tOut.match(/^\s*\d+ fail.*$/m) || [""])[0] +info(summary.trim() || "(no summary line)") +if (t.status !== 0 || /\b\d+ fail\b/.test(tOut)) { + console.error(tOut.slice(-4000)) + gitOrFail(["checkout", startBranch], "restoring branch") + fail("the fix's 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 = binaryHasMarkers(artifact, fix.markers) +for (const r of results) { + if (r.found) ok(`marker present: ${r.marker}`) + else console.error(` MISSING: ${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) — the fix did not survive bundling. Not staging.`) +} + +// ---------------------------------------------------------------- 8. stage +step++ +console.log(`\n[${step}] stage the binary`) +const stagePath = manifest.stage.path +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. ${manifest.fix.id} is applied and verified for opencode ${version}.`) +console.log(`${manifest.restartNote}`) +console.log(`Rollback: \`bun run fixes:apply ${fix.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..f130e5433c88 --- /dev/null +++ b/local-fixes/manifest.json @@ -0,0 +1,42 @@ +{ + "version": 1, + "purpose": "Upgrade the patched opencode 1.x binary to a newer upstream tag while re-applying our compaction-anchor fix, then verify the fix 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.", + "fix": { + "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." + }, + "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.exe", + "note": "Stable filename on purpose: opencodeBinary in OpenChamber settings.json points here forever, so an upgrade never requires editing settings again.", + "settingsFile": "C:\\Users\\Zephyrus\\.config\\openchamber\\settings.json", + "settingsKey": "opencodeBinary" + }, + "buildBranchPrefix": "build/", + "restartNote": "OpenChamber must be fully closed and restarted to load a newly staged binary." +} 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/verify.mjs b/local-fixes/verify.mjs new file mode 100644 index 000000000000..f8002fd68c30 --- /dev/null +++ b/local-fixes/verify.mjs @@ -0,0 +1,95 @@ +#!/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 } from "node:child_process" +import { existsSync, readFileSync, statSync } 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")) +const fix = manifest.fix + +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 — ${fix.id} ===`) + +// 1. staged binary present +console.log(`\n[1] staged binary`) +const stagePath = manifest.stage.path +if (!existsSync(stagePath)) { + warn(`missing: ${stagePath}`) + 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 m of fix.markers) { + if (hay.includes(m)) good(`present: ${m}`) + else warn(`MISSING: ${m}`) +} + +// 4. fix commit still in this repo +console.log(`\n[4] fix commit`) +const has = spawnSync("git", ["cat-file", "-e", `${fix.commit}^{commit}`], { cwd: ROOT, encoding: "utf8" }) +if (has.status === 0) good(`${fix.commit.slice(0, 10)} present`) +else warn(`fix 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? +console.log(`\n[6] OpenChamber pin`) +const settingsFile = manifest.stage.settingsFile +if (!existsSync(settingsFile)) { + note(`(no settings file at ${settingsFile})`) +} else { + try { + const s = JSON.parse(readFileSync(settingsFile, "utf8")) + const pinned = s[manifest.stage.settingsKey] + if (pinned === stagePath) good(`settings.json ${manifest.stage.settingsKey} → staged binary`) + else if (!pinned) warn(`settings.json ${manifest.stage.settingsKey} is empty — OpenChamber uses its BUNDLED binary, not this one`) + else warn(`settings.json points at "${pinned}" — NOT the staged binary`) + } catch (e) { + warn(`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": { From 489b951e48a2fd98316963e0bc8851c2628b98ee Mon Sep 17 00:00:00 2001 From: "Mohamed R. Khalil" Date: Fri, 25 Sep 2026 13:31:07 +0300 Subject: [PATCH 04/24] fix(local-fixes): correct two logic errors in apply.mjs - the test gate matched the literal "0 fail" and so rejected passing runs; it now reads the numeric counts and fails only when failed>0 or passed==0 - preflight used `!x === 0`, which is always false, so an explicitly named tag was never fetched before being checked --- local-fixes/apply.mjs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/local-fixes/apply.mjs b/local-fixes/apply.mjs index 21537c381219..86e8512495b9 100644 --- a/local-fixes/apply.mjs +++ b/local-fixes/apply.mjs @@ -90,8 +90,8 @@ ok(`target ${tag} (version ${version})`) // ---------------------------------------------------------------- 2. preflight step++ console.log(`\n[${step}] preflight`) -if (!git(["cat-file", "-e", `${tag}^{commit}`]).code === 0) { - // tag may not be fetched yet if an explicit arg was passed +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}`) @@ -186,9 +186,15 @@ const t = spawnSync("bun", ["test", `test/${fix.tests.replace(/^test\//, "")}`, stdio: ["ignore", "pipe", "pipe"], }) const tOut = `${t.stdout || ""}\n${t.stderr || ""}` -const summary = (tOut.match(/^\s*\d+ pass.*$/m) || tOut.match(/^\s*\d+ fail.*$/m) || [""])[0] -info(summary.trim() || "(no summary line)") -if (t.status !== 0 || /\b\d+ fail\b/.test(tOut)) { +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 fix's tests did not pass on this base — refusing to stage a binary") From c94499b2eea74ba0a3a0ba8548ed57d4921690be Mon Sep 17 00:00:00 2001 From: "Mohamed R. Khalil" Date: Fri, 25 Sep 2026 19:19:14 +0300 Subject: [PATCH 05/24] fix(local-fixes): pin OPENCODE_BINARY, not settings.json OpenChamber resolves its opencode binary at startup as process.env.OPENCODE_BINARY || searchPathFor('opencode') so the environment variable is the only durable pin. settings.json's opencodeBinary is UI-managed state: OpenChamber drops the key on every settings rewrite (observed twice on 2026-09-25 at 18:57:50 and 19:10:49) and never reads it at startup. On the 2026-09-25 restart the app therefore came up on its bundled 1.18.31 with the compaction fix silently absent, despite pin-opencode.ps1 having written and verified the settings key. - pin-opencode.ps1: sets the User-scope OPENCODE_BINARY (primary, and the only thing that matters), broadcasts WM_SETTINGCHANGE so newly launched apps inherit it, and writes the settings key only as cosmetic best-effort while OpenChamber is closed. - verify.mjs [6]: checks the environment variable. It previously reported 'settings.json -> staged binary' as ok, which was a pin that did not exist. settings.json is now reported as cosmetic and never trusted. - manifest.json: stage.pinMechanism documents the real mechanism; stage.settingsKeyNote records that the key is dropped on rewrite. Verified: fixes:verify [6] reports 'OPENCODE_BINARY (User) -> staged binary'; managed-opencode/50192.json records opencode-patched.exe at 1.18.32 after restart. --- local-fixes/manifest.json | 9 ++-- local-fixes/pin-opencode.ps1 | 95 ++++++++++++++++++++++++++++++++++++ local-fixes/verify.mjs | 35 +++++++++---- 3 files changed, 126 insertions(+), 13 deletions(-) create mode 100644 local-fixes/pin-opencode.ps1 diff --git a/local-fixes/manifest.json b/local-fixes/manifest.json index f130e5433c88..81ad083f3142 100644 --- a/local-fixes/manifest.json +++ b/local-fixes/manifest.json @@ -33,10 +33,13 @@ }, "stage": { "path": "C:\\Users\\Zephyrus\\.local\\bin\\opencode-patched.exe", - "note": "Stable filename on purpose: opencodeBinary in OpenChamber settings.json points here forever, so an upgrade never requires editing settings again.", + "note": "Stable filename on purpose: the User environment variable OPENCODE_BINARY points here forever, so an upgrade never requires re-pinning.", + "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" + "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/", - "restartNote": "OpenChamber must be fully closed and restarted to load a newly staged binary." + "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/pin-opencode.ps1 b/local-fixes/pin-opencode.ps1 new file mode 100644 index 000000000000..d4af9fce761d --- /dev/null +++ b/local-fixes/pin-opencode.ps1 @@ -0,0 +1,95 @@ +# 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' +$target = 'C:\Users\Zephyrus\.local\bin\opencode-patched.exe' + +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." + } +} + +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 index f8002fd68c30..38befaf14fad 100644 --- a/local-fixes/verify.mjs +++ b/local-fixes/verify.mjs @@ -7,7 +7,7 @@ * * bun run fixes:verify */ -import { spawnSync } from "node:child_process" +import { spawnSync, execFileSync } from "node:child_process" import { existsSync, readFileSync, statSync } from "node:fs" import { join, dirname } from "node:path" import { fileURLToPath } from "node:url" @@ -71,19 +71,34 @@ 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? -console.log(`\n[6] OpenChamber pin`) +// 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)`) +let pinned = "" +try { + pinned = execFileSync("powershell.exe", [ + "-NoProfile", "-Command", + "[Environment]::GetEnvironmentVariable('OPENCODE_BINARY','User')", + ], { encoding: "utf8" }).trim() +} catch (e) { + warn(`could not read the User environment: ${e.message}`) +} +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)) { - note(`(no settings file at ${settingsFile})`) -} else { +if (existsSync(settingsFile)) { try { const s = JSON.parse(readFileSync(settingsFile, "utf8")) - const pinned = s[manifest.stage.settingsKey] - if (pinned === stagePath) good(`settings.json ${manifest.stage.settingsKey} → staged binary`) - else if (!pinned) warn(`settings.json ${manifest.stage.settingsKey} is empty — OpenChamber uses its BUNDLED binary, not this one`) - else warn(`settings.json points at "${pinned}" — NOT the staged binary`) + 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) { - warn(`could not read ${settingsFile}: ${e.message}`) + note(`(could not read ${settingsFile}: ${e.message})`) } } From d49d13860748033d942fa53b679a301ddfb7ef66 Mon Sep 17 00:00:00 2001 From: "Mohamed R. Khalil" Date: Fri, 25 Sep 2026 21:14:07 +0300 Subject: [PATCH 06/24] docs: PR draft for the compaction anchor fix Every claim in it was verified against the actual patch before committing: - prompt.ts: parentID lastUser.id -> task.messageID (patch lines 172/178) - diffstat 3 files, +230 -11 - message-v2.ts:534 (the forced-tail premise was false) - 9/9 rule replay, 37-hour runaway, +156 test lines, 59 pass / 1 skip / 0 fail It separates the fix from the circuit-breaker gap deliberately: the absence of any guard against repeated compaction is a different defect and deserves its own change, not to be smuggled into this one. It also states plainly that the model-resolution expression is byte-identical to upstream, so a reviewer does not suspect the patch of changing model selection. --- PR-DRAFT.md | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 PR-DRAFT.md diff --git a/PR-DRAFT.md b/PR-DRAFT.md new file mode 100644 index 000000000000..25aedb1b8b19 --- /dev/null +++ b/PR-DRAFT.md @@ -0,0 +1,95 @@ +# fix(session): anchor the compaction summary to the marker message + +## 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 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. + +## The gap that let it run for 37 hours + +Worth fixing separately, and the reason this went unnoticed so long: **there is no circuit breaker on repeated compaction.** The orphaned summary is invisible to the completion check, so the marker looks unprocessed forever and compaction re-fires indefinitely. One observed instance ran for **37 hours**. + +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`, +156 lines. **59 pass / 1 skip / 0 fail**; typecheck clean across 30 packages. Three of the four new guards were confirmed red-green (they fail against the unfixed code). + +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. From 60372752674fe3a46e5d54b0fc56768e43aef59c Mon Sep 17 00:00:00 2001 From: "Mohamed R. Khalil" Date: Sat, 26 Sep 2026 01:00:06 +0300 Subject: [PATCH 07/24] 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 7affc3749859..98ab7a2d6c85 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 ab084a22daec..bac8cd5bfb50 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", () => { From 84a27933e63f6350eb90bda395f2af8502dc76b2 Mon Sep 17 00:00:00 2001 From: "Mohamed R. Khalil" Date: Sat, 26 Sep 2026 01:08:18 +0300 Subject: [PATCH 08/24] fix(local-fixes): apply ordered OpenCode fixes --- local-fixes/apply.mjs | 113 ++++++++++++++++++++------------------ local-fixes/manifest.json | 58 ++++++++++++------- 2 files changed, 99 insertions(+), 72 deletions(-) diff --git a/local-fixes/apply.mjs b/local-fixes/apply.mjs index 86e8512495b9..3bd2043c1b30 100644 --- a/local-fixes/apply.mjs +++ b/local-fixes/apply.mjs @@ -1,17 +1,17 @@ #!/usr/bin/env bun /** * fixes:apply — upgrade the patched opencode binary to a newer upstream tag, - * re-applying our compaction-anchor fix, then verify it survived bundling + * 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 * - * The fix commit is IMMUTABLE. Every run creates a fresh build/ - * branch from the target tag and cherry-picks the fix onto it, so an upgrade - * can never rewrite or lose the fix, and a failed upgrade leaves the repo - * exactly as it was found. + * 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" @@ -28,6 +28,8 @@ 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 }) @@ -60,8 +62,7 @@ function binaryHasMarkers(file, markers) { return markers.map((m) => ({ marker: m, found: hay.includes(m) })) } -console.log(`\n=== fixes:apply — opencode ${manifest.fix.id} ===`) -const fix = manifest.fix +console.log(`\n=== fixes:apply — opencode ${fixes.map((fix) => fix.id).join(", ")} ===`) const startBranch = gitOrFail(["branch", "--show-current"], "reading current branch") // ---------------------------------------------------------------- 1. resolve target @@ -95,10 +96,12 @@ if (git(["cat-file", "-e", `${tag}^{commit}`]).code !== 0) { gitOrFail(["fetch", "origin", "--tags", "--quiet"], "fetching upstream tags") } if (git(["cat-file", "-e", `${tag}^{commit}`]).code !== 0) fail(`tag does not exist: ${tag}`) -if (git(["cat-file", "-e", `${fix.commit}^{commit}`]).code !== 0) { - fail(`fix commit is missing from this repo: ${fix.commit}`) +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)}`) } -ok(`fix commit present: ${fix.commit.slice(0, 10)}`) const dirty = git(["status", "--porcelain"]) .out.split("\n") @@ -117,43 +120,46 @@ const buildBranch = `${manifest.buildBranchPrefix}${version}` gitOrFail(["checkout", "-B", buildBranch, tag], `creating ${buildBranch}`) ok(`on ${buildBranch}`) -// ---------------------------------------------------------------- 4. re-apply the fix +// ---------------------------------------------------------------- 4. re-apply every fix step++ -console.log(`\n[${step}] re-apply the fix`) -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) { +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("patch applied but commit failed") + 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 } - 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 the fix at ${tag}.\n` + - ` Nothing was changed — you are back on ${startBranch}.\n` + - ` Rebase ${fix.commit.slice(0, 10)} by hand, then update manifest.fix.commit.`, - ) + 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 @@ -177,10 +183,11 @@ ok("build succeeded") const artifact = join(ROOT, manifest.build.cwd, manifest.build.artifact) if (!existsSync(artifact)) fail(`built artifact missing: ${artifact}`) -// ---------------------------------------------------------------- 6. run the fix's tests +// ---------------------------------------------------------------- 6. run all unique fix tests step++ -console.log(`\n[${step}] run ${fix.tests}`) -const t = spawnSync("bun", ["test", `test/${fix.tests.replace(/^test\//, "")}`, "--timeout", "30000"], { +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"], @@ -197,22 +204,24 @@ 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 fix's tests did not pass on this base — refusing to stage a binary") + 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 = binaryHasMarkers(artifact, fix.markers) +const results = fixes.flatMap((fix) => + binaryHasMarkers(artifact, fix.markers).map((result) => ({ ...result, fix: fix.id })), +) for (const r of results) { - if (r.found) ok(`marker present: ${r.marker}`) - else console.error(` MISSING: ${r.marker}`) + 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) — the fix did not survive bundling. Not staging.`) + fail(`binary is missing ${missing.length} marker(s) — one or more fixes did not survive bundling. Not staging.`) } // ---------------------------------------------------------------- 8. stage @@ -257,6 +266,6 @@ 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. ${manifest.fix.id} is applied and verified for opencode ${version}.`) +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 ${fix.base}\`, or delete ${stagePath} to fall back to the bundled binary.`) +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 index 81ad083f3142..36ed34af307c 100644 --- a/local-fixes/manifest.json +++ b/local-fixes/manifest.json @@ -1,29 +1,47 @@ { "version": 1, - "purpose": "Upgrade the patched opencode 1.x binary to a newer upstream tag while re-applying our compaction-anchor fix, then verify the fix survived bundling before staging it for OpenChamber.", + "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.", - "fix": { - "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." - }, + "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." + } + ], "build": { "cwd": "packages/opencode", "command": "bun run script/build.ts --single --skip-embed-web-ui --skip-install", From 2d16c3bbe5854aa1d516f254c56437a071a15343 Mon Sep 17 00:00:00 2001 From: "Mohamed R. Khalil" Date: Sat, 26 Sep 2026 01:08:39 +0300 Subject: [PATCH 09/24] fix(local-fixes): verify every OpenCode fix --- .../patches/compaction-terminal-state.patch | 181 ++++++++++++++++++ local-fixes/verify.mjs | 23 ++- 2 files changed, 195 insertions(+), 9 deletions(-) create mode 100644 local-fixes/patches/compaction-terminal-state.patch 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/verify.mjs b/local-fixes/verify.mjs index 38befaf14fad..cc9a94e4cca8 100644 --- a/local-fixes/verify.mjs +++ b/local-fixes/verify.mjs @@ -14,7 +14,8 @@ 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 fix = manifest.fix +const fixes = manifest.fixes ?? (manifest.fix ? [manifest.fix] : []) +if (!fixes.length) throw new Error("manifest contains no local fixes") let bad = 0 const line = (mark, msg) => console.log(` ${mark} ${msg}`) @@ -22,7 +23,7 @@ const good = (m) => line("ok ", m) const warn = (m) => { bad++; line("FAIL", m) } const note = (m) => line(" ", m) -console.log(`\n=== fixes:verify — ${fix.id} ===`) +console.log(`\n=== fixes:verify — ${fixes.map((fix) => fix.id).join(", ")} ===`) // 1. staged binary present console.log(`\n[1] staged binary`) @@ -52,16 +53,20 @@ if (!reported) { // 3. markers inside the binary console.log(`\n[3] fix markers inside the staged binary`) const hay = readFileSync(stagePath).toString("latin1") -for (const m of fix.markers) { - if (hay.includes(m)) good(`present: ${m}`) - else warn(`MISSING: ${m}`) +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}`) + } } // 4. fix commit still in this repo -console.log(`\n[4] fix commit`) -const has = spawnSync("git", ["cat-file", "-e", `${fix.commit}^{commit}`], { cwd: ROOT, encoding: "utf8" }) -if (has.status === 0) good(`${fix.commit.slice(0, 10)} present`) -else warn(`fix commit ${fix.commit.slice(0, 10)} not found — has it been dropped?`) +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`) From c4d6f7d9512741138b537a1a4820e05512817292 Mon Sep 17 00:00:00 2001 From: "Mohamed R. Khalil" Date: Sat, 26 Sep 2026 01:09:02 +0300 Subject: [PATCH 10/24] docs: add terminal-state fix to OpenCode PR draft --- PR-DRAFT.md | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/PR-DRAFT.md b/PR-DRAFT.md index 25aedb1b8b19..208af9a5544f 100644 --- a/PR-DRAFT.md +++ b/PR-DRAFT.md @@ -1,4 +1,4 @@ -# fix(session): anchor the compaction summary to the marker message +# fix(session): make compaction summaries marker-safe and terminal ## Summary @@ -9,6 +9,11 @@ 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` @@ -61,15 +66,41 @@ The commit touches one line in `prompt.ts` and the logic around it in `compactio 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. -## The gap that let it run for 37 hours +## 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 -Worth fixing separately, and the reason this went unnoticed so long: **there is no circuit breaker on repeated compaction.** The orphaned summary is invisible to the completion check, so the marker looks unprocessed forever and compaction re-fires indefinitely. One observed instance ran 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`, +156 lines. **59 pass / 1 skip / 0 fail**; typecheck clean across 30 packages. Three of the four new guards were confirmed red-green (they fail against the unfixed code). +`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`. From ffb1b959868423ec08ad1c64481162da7d12aefa Mon Sep 17 00:00:00 2001 From: "Mohamed R. Khalil" Date: Sat, 26 Sep 2026 03:29:57 +0300 Subject: [PATCH 11/24] fix(local-fixes): stage versioned binaries instead of one stable filename --- local-fixes/apply.mjs | 2 +- local-fixes/manifest.json | 4 +-- local-fixes/pin-opencode.ps1 | 15 +++++++++- local-fixes/verify.mjs | 56 +++++++++++++++++++++++++++--------- 4 files changed, 59 insertions(+), 18 deletions(-) diff --git a/local-fixes/apply.mjs b/local-fixes/apply.mjs index 3bd2043c1b30..6d4b61c076a4 100644 --- a/local-fixes/apply.mjs +++ b/local-fixes/apply.mjs @@ -227,7 +227,7 @@ if (missing.length) { // ---------------------------------------------------------------- 8. stage step++ console.log(`\n[${step}] stage the binary`) -const stagePath = manifest.stage.path +const stagePath = manifest.stage.path.replace("{version}", version) copyFileSync(artifact, stagePath) ok(`copied → ${stagePath}`) diff --git a/local-fixes/manifest.json b/local-fixes/manifest.json index 36ed34af307c..b0c39ae90dc8 100644 --- a/local-fixes/manifest.json +++ b/local-fixes/manifest.json @@ -50,8 +50,8 @@ "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.exe", - "note": "Stable filename on purpose: the User environment variable OPENCODE_BINARY points here forever, so an upgrade never requires re-pinning.", + "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", diff --git a/local-fixes/pin-opencode.ps1 b/local-fixes/pin-opencode.ps1 index d4af9fce761d..af68bf7550d2 100644 --- a/local-fixes/pin-opencode.ps1 +++ b/local-fixes/pin-opencode.ps1 @@ -16,7 +16,20 @@ $ProgressPreference = 'SilentlyContinue' $ErrorActionPreference = 'Stop' $settings = 'C:\Users\Zephyrus\.config\openchamber\settings.json' -$target = 'C:\Users\Zephyrus\.local\bin\opencode-patched.exe' +# 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 ===" diff --git a/local-fixes/verify.mjs b/local-fixes/verify.mjs index cc9a94e4cca8..91be80df47b8 100644 --- a/local-fixes/verify.mjs +++ b/local-fixes/verify.mjs @@ -8,8 +8,8 @@ * bun run fixes:verify */ import { spawnSync, execFileSync } from "node:child_process" -import { existsSync, readFileSync, statSync } from "node:fs" -import { join, dirname } from "node:path" +import { existsSync, readFileSync, statSync, readdirSync } from "node:fs" +import { join, dirname, basename } from "node:path" import { fileURLToPath } from "node:url" const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..") @@ -17,6 +17,41 @@ const manifest = JSON.parse(readFileSync(join(ROOT, "local-fixes/manifest.json") 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) @@ -27,9 +62,9 @@ console.log(`\n=== fixes:verify — ${fixes.map((fix) => fix.id).join(", ")} === // 1. staged binary present console.log(`\n[1] staged binary`) -const stagePath = manifest.stage.path -if (!existsSync(stagePath)) { - warn(`missing: ${stagePath}`) +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) @@ -81,15 +116,8 @@ else note(`(no ${branch} — staged binary may predate this workflow, which is f // 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)`) -let pinned = "" -try { - pinned = execFileSync("powershell.exe", [ - "-NoProfile", "-Command", - "[Environment]::GetEnvironmentVariable('OPENCODE_BINARY','User')", - ], { encoding: "utf8" }).trim() -} catch (e) { - warn(`could not read the User environment: ${e.message}`) -} +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`) From 04a1cff12d587776cd5823ebc4ce3ceabacffbf2 Mon Sep 17 00:00:00 2001 From: "Mohamed R. Khalil" Date: Sun, 27 Sep 2026 23:37:14 +0300 Subject: [PATCH 12/24] fix(compaction): pin summarizer variant from the configured compaction agent (compaction-model-override) --- packages/opencode/src/session/compaction.ts | 8 ++- .../opencode/test/session/compaction.test.ts | 50 +++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 98ab7a2d6c85..21f80697761b 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -387,11 +387,17 @@ const layer = Layer.effect( 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 // 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, + variant: summarizerVariant, providerID: model.providerID, modelID: model.id, requestedProviderID: userMessage.model.providerID, @@ -439,7 +445,7 @@ const layer = Layer.effect( sessionID: input.sessionID, mode: "compaction", agent: "compaction", - variant: userMessage.model.variant, + variant: summarizerVariant, summary: true, path: { cwd: ctx.directory, diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index bac8cd5bfb50..b403c39e22f5 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -225,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, @@ -1052,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", () => { From 90db49ec7deaead90c370498f3f68c2a7ef874b9 Mon Sep 17 00:00:00 2001 From: "Mohamed R. Khalil" Date: Sun, 27 Sep 2026 23:37:37 +0300 Subject: [PATCH 13/24] feat(core): forward-port compaction.model override to the v2-layout core tree (config Settings + resolveRef seam, not in the v1 build) --- packages/core/src/config/compaction.ts | 4 + packages/core/src/session/compaction.ts | 23 +++- packages/core/src/session/runner/llm.ts | 4 +- packages/core/src/session/runner/model.ts | 27 ++++- packages/core/test/session-compaction.test.ts | 103 ++++++++++++++++++ 5 files changed, 157 insertions(+), 4 deletions(-) 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..65de5004b498 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 + } | 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,20 @@ 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 overrideModel = + config.model !== undefined && dependencies.models?.resolveRef !== undefined + ? yield* dependencies.models.resolveRef(config.model).pipe( + Effect.catchCause(() => Effect.succeed(undefined)), + ) + : undefined + 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" }) + }), + ) +}) From 087d92467a2e462643c11eb6e46c94caab71c449 Mon Sep 17 00:00:00 2001 From: "Mohamed R. Khalil" Date: Sun, 27 Sep 2026 23:38:23 +0300 Subject: [PATCH 14/24] docs(local-fixes): register compaction-model-override as fix 3 --- local-fixes/manifest.json | 17 +++++++++++++++++ .../patches/compaction-model-override.patch | Bin 0 -> 10268 bytes 2 files changed, 17 insertions(+) create mode 100644 local-fixes/patches/compaction-model-override.patch diff --git a/local-fixes/manifest.json b/local-fixes/manifest.json index b0c39ae90dc8..b81a292076e6 100644 --- a/local-fixes/manifest.json +++ b/local-fixes/manifest.json @@ -40,6 +40,23 @@ ], "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", + "summarizerVariant" + ], + "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." } ], "build": { diff --git a/local-fixes/patches/compaction-model-override.patch b/local-fixes/patches/compaction-model-override.patch new file mode 100644 index 0000000000000000000000000000000000000000..579876ab39631866c08e6d8ecda2a40565240e2a GIT binary patch literal 10268 zcmchd?Qj$}K;mW?En2#$Xp zcz)9w&&=+uY~zI5(n`BK)6>)a^wT}F`u(^6xT!mJC$4h0-Hsc&P50a#=-6~yZsc~| zJ$Kh1cXZ#bx7@ZHyM0~n=rh(8=b`Ql^^3my`fN$!7m_^D_nw}g>3dU>80WToken;E z<=&?IyZf69`aDp}o|kYWxhIk^(w(net@G!Od&lmDK0RH3py##bF;tsZI!|=*)h&yQ4endbaJg zM|ULbyB{R)xuhcVJNKn~?7ndyyU*PJbbTNlUc3HMhck^%%NO2GYre;suCUomvQE6# zQ{DMlGN-!xlX`H?9OqtM@ZMbW9qSIWoA_}KC1I+2BR^Anaw0#ZV^+F4R2!34)@vXw zP9*6^0cc%VVAIaLsF&S}JUbYb#b@gF3AOi6Bu^%7% zgW8F=_sHXH*W(O-V}t1h5@tfkx-1J8H}x^wk1wA0HS1{ik;WkEDsTToU4e|D=4-xw z^Iq70YqGH%_F%@ZHH-V=ADHE?&hQZ|grB!$mjgY!tvmPi^sbN3O&^JO6q!376^6*S zX$$l=}@()>ZjdU6f7S}WrKFkcCML$~ngs@y|e|M%j$)?IL8 zY}H@Jk;RyuQ#p|XOiqbs;CsWWML;Tx-;eZJ$iFbj$o*4SXF@%U^1}C<%CoT1-1ELk zGEQnTS{$iv$#~*E@sYDy!#ZI#daP+|xEGe{yH69#)Y5RO7U*qJ*PK`7nfv-PJ732A z7m^GE8W*RUbE^pST5vJQES42PAQS!H#`sN6eMr94f( zBGaXrt0^hZPnH|W14ED2l9qAKuqRwd`)1yZl3w{XN6WVV(s$5@ieaic0%m@{DCI;M z)}m}8D_FEq3B8mQvhZAIV~A+WO18wBLwy2sGeXG0QGc=rl@A$YA#}DSrSVy+7ryl~ zh}8n|zd9-`Mq^D-w=9RxuY$eY;%z=KE0iVSH@sHz1YVuVuF`RYhuPqgd zSr?R|A#W&h!d%v>cAMRJS}k*Z0gHenO7%cJZRKIHK;&R2 zB8TVnix_}?4kf|jAe}=qr{vsi{hmnHfh6>`UMTxNMG?^m#*V#@M(P9Veku4LJ!na# z%_=A`Vt2l>2s163tp-Ki2C_@2(OK;j0)JBRmUYUR0^Kg>GLw~~z~-0N6XZQ4M(=iI)HXvQFZL@*qH5{VB=-;V z|I*_<);d9AnFFs<1)y`N3wKlr-j_C1f$P2k-d$e3*_sfmfuGNW$hl&)-J;7* z!NP{>Qfvk^x4j+X;_}`D>75^K++D>Jd9S|$(`hs;Jeuc15|Q6!Q`UXtE3DGRTG-;b z_btb!BMBG?wQC9jlU3=bK_v@}hZ$4t+9xMRcut&D;?ZMTi?o~^EIuKqb9#yv2 z1@;b<)yg|gk31$n76L`Wm(JdD?dV`d*WV+Gng9tI6iL^t0V75C?NI zT8RlO_p6u6Ijlr~T~;NQTi?b?4NZa{^8Exj2BryhMOw3$^^c5g`gY+08#n8Y#}`!) z^>(O;;JBw=!>95x6)d$Z73K$-N81RBGQiD;+N-6KqB5smO_5~lOtXWn_kyQvFJw>N zVl@jE;86I}RvynJ>!m)YV%mu?wb1!_18ccvYkK<2($7|XV~b$_ysg;~Cciq$V;}mZ zvfG%?+y1rqkKVB?R*nds#g@cJ8Y5|Tws=`r3!4>ZlgaKY7`AG4=utFpRcq#HR)Vx6 zy4w=)dK0z5YG9O}&0V-~zZT}GU0F5ug+-H&kM`6n?vLZK<`}g{7}9OVftSfiyW-=( z%v|3QlipGWi@SyNn+cU5pH(1hq`ZC$?@aP{2ceRNAJNZNNh$B8Jj_gtAz4e~w@}64 zv9yv2mNh@oj~?ZD6RUWS^(}hXE@s>p>RRokmboL#1r2?mq;&o66~|DcjkM+B%@^i)nxRfQM-2- z2P9BCAGPey7#v7tFJz$>pew%HT3p};U)uVC9B!G@5R}g}$h=JF@7;SsT;KC~SeN9! zCST!k_E;W?xtqJutZZ1Xvv1wgxhJG>In@Xl0AU2u1X$YM%^RsWhgK4S9$a>QhA1{ z1y7_sdt$3>hxW|R7>_rLuyD@rVFjPxHCq@aFV}_KfS2q|GCR#dyV`q>cb1#WIK!^& z#@PsE+3bq)E{2t8U%#Ss-qf%<{kNWeqcPcWIP%OAESE+}+ebNeOMa;(6I%pE2pP_7 zm+BHMm)?6<5p1YQ?M}uE7U6kr>mVn;8_`Hr!km%9TY+QWKlM%7iYZ!h)-}xZ8~6X^^;KCz{L`0ZBIom8j))LyBHJ&1sGI<j`-K*R`bYSDOcRuG5 zvod}cZgLFfvaXN9TpF>TX2Ew8y~DnH$W260Sns6ccI{?&v(-&>$alV)dQkhkTEUa~ z$lbX+$R#e}68^O_5kh#^0h94Rj_@npi7wBXd`c9D_qt^ahW9%qE+FR1y+DrDRzzj2 zh3sB@&)$VP`dfSwBZtdqM$i5KnvF6*9`HfBQyNvafMQ9uu&ic_C`tUM=unn93u0 aOA)cFqt Date: Sun, 27 Sep 2026 23:39:39 +0300 Subject: [PATCH 15/24] docs: session handoffs and specs (MCP flatten, corpus redesign) --- HANDOFF-20260925-CONTINUE.md | 133 +++++++++++++++++ HANDOFF-20260925.md | 178 +++++++++++++++++++++++ HANDOFF-20260926-MCP-FLATTEN.md | 69 +++++++++ specs/corpus-incident-redesign-design.md | 69 +++++++++ 4 files changed, 449 insertions(+) create mode 100644 HANDOFF-20260925-CONTINUE.md create mode 100644 HANDOFF-20260925.md create mode 100644 HANDOFF-20260926-MCP-FLATTEN.md create mode 100644 specs/corpus-incident-redesign-design.md 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..7bcf2d17d700 --- /dev/null +++ b/HANDOFF-20260926-MCP-FLATTEN.md @@ -0,0 +1,69 @@ +# 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. 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. From a0798bafa7b81cf6d16821b776f7119f4d4071ba Mon Sep 17 00:00:00 2001 From: "Mohamed R. Khalil" <m.radwan.khalil@gmail.com> Date: Sun, 27 Sep 2026 23:41:18 +0300 Subject: [PATCH 16/24] fix(local-fixes): compaction-model-override marker must be a runtime string (identifier was minified away) --- local-fixes/manifest.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/local-fixes/manifest.json b/local-fixes/manifest.json index b81a292076e6..021758f54349 100644 --- a/local-fixes/manifest.json +++ b/local-fixes/manifest.json @@ -52,8 +52,7 @@ "packages/opencode/test/session/compaction.test.ts" ], "markers": [ - "compaction-model-override", - "summarizerVariant" + "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." From ba4d5fc95bf1fea8682951447ba3225f095c99ef Mon Sep 17 00:00:00 2001 From: "Mohamed R. Khalil" <m.radwan.khalil@gmail.com> Date: Mon, 28 Sep 2026 01:29:58 +0300 Subject: [PATCH 17/24] fix(core): typecheck-clean the compaction.model forward-port (function-scope narrowing) --- packages/core/src/session/compaction.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 65de5004b498..995a844292d9 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -210,12 +210,15 @@ export const make = (dependencies: Dependencies) => { // 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 overrideModel = - config.model !== undefined && dependencies.models?.resolveRef !== undefined - ? yield* dependencies.models.resolveRef(config.model).pipe( - Effect.catchCause(() => Effect.succeed(undefined)), - ) - : undefined + const pinnedRef = config.model + const noOverride = () => Effect.succeed<Model | undefined>(undefined) + const resolveRef: (ref: string) => Effect.Effect<Model | undefined, unknown> = + dependencies.models?.resolveRef ?? noOverride + const overrideEffect: Effect.Effect<Model | undefined, never> = + pinnedRef === undefined + ? Effect.succeed(undefined) + : resolveRef(pinnedRef).pipe(Effect.catchCause(() => Effect.succeed<Model | undefined>(undefined))) + const overrideModel = yield* overrideEffect const summarizeModel = overrideModel ?? input.model const summarized = yield* dependencies.llm .stream( From 286c9d2ef2405a39fc7062d31969cc77a78272ab Mon Sep 17 00:00:00 2001 From: "Mohamed R. Khalil" <m.radwan.khalil@gmail.com> Date: Mon, 28 Sep 2026 01:30:00 +0300 Subject: [PATCH 18/24] 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 21f80697761b..4fd30932b65f 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 = <T extends SummarizerModelRef>(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 b403c39e22f5..c66e5a46907e 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 From 4c7268973175082cb0ecc180f06ab1611a661c72 Mon Sep 17 00:00:00 2001 From: "Mohamed R. Khalil" <m.radwan.khalil@gmail.com> Date: Mon, 28 Sep 2026 01:30:01 +0300 Subject: [PATCH 19/24] docs(local-fixes): register compaction-pin-precedence as fix 4 --- local-fixes/manifest.json | 20 ++ .../patches/compaction-pin-precedence.patch | 171 ++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 local-fixes/patches/compaction-pin-precedence.patch diff --git a/local-fixes/manifest.json b/local-fixes/manifest.json index 021758f54349..c93401e8f28c 100644 --- a/local-fixes/manifest.json +++ b/local-fixes/manifest.json @@ -56,6 +56,26 @@ ], "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": "934f08a31d579b42dd4ba462434d2631218ac207", + "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.<key>.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)." } ], "build": { diff --git a/local-fixes/patches/compaction-pin-precedence.patch b/local-fixes/patches/compaction-pin-precedence.patch new file mode 100644 index 000000000000..0da7524978e2 --- /dev/null +++ b/local-fixes/patches/compaction-pin-precedence.patch @@ -0,0 +1,171 @@ +From 934f08a31d579b42dd4ba462434d2631218ac207 Mon Sep 17 00:00:00 2001 +From: "Mohamed R. Khalil" <m.radwan.khalil@gmail.com> +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 = <T extends SummarizerModelRef>(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 + From 7f7962399889819e80341a1a122f61f65fd85b78 Mon Sep 17 00:00:00 2001 From: "Mohamed R. Khalil" <m.radwan.khalil@gmail.com> Date: Mon, 28 Sep 2026 01:34:05 +0300 Subject: [PATCH 20/24] docs: session handoff update --- HANDOFF-20260926-MCP-FLATTEN.md | 11 +++++++++++ local-fixes/manifest.json | 2 +- local-fixes/patches/compaction-pin-precedence.patch | 2 +- packages/core/src/session/compaction.ts | 2 +- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/HANDOFF-20260926-MCP-FLATTEN.md b/HANDOFF-20260926-MCP-FLATTEN.md index 7bcf2d17d700..1f5da42e3aa2 100644 --- a/HANDOFF-20260926-MCP-FLATTEN.md +++ b/HANDOFF-20260926-MCP-FLATTEN.md @@ -67,3 +67,14 @@ Exactly the mk-canon-update flow above (bank `agents_configs`, corpus-first). No ## 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/<pid>.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/local-fixes/manifest.json b/local-fixes/manifest.json index c93401e8f28c..cd790839f630 100644 --- a/local-fixes/manifest.json +++ b/local-fixes/manifest.json @@ -60,7 +60,7 @@ { "id": "compaction-pin-precedence", "title": "Plugin pin beats the configured compaction agent; the agent is the overflow fallback", - "commit": "934f08a31d579b42dd4ba462434d2631218ac207", + "commit": "286c9d2ef2405a39fc7062d31969cc77a78272ab", "patch": "local-fixes/patches/compaction-pin-precedence.patch", "base": "v1.18.32", "files": [ diff --git a/local-fixes/patches/compaction-pin-precedence.patch b/local-fixes/patches/compaction-pin-precedence.patch index 0da7524978e2..16efd10227e2 100644 --- a/local-fixes/patches/compaction-pin-precedence.patch +++ b/local-fixes/patches/compaction-pin-precedence.patch @@ -1,4 +1,4 @@ -From 934f08a31d579b42dd4ba462434d2631218ac207 Mon Sep 17 00:00:00 2001 +From 286c9d2ef2405a39fc7062d31969cc77a78272ab Mon Sep 17 00:00:00 2001 From: "Mohamed R. Khalil" <m.radwan.khalil@gmail.com> Date: Mon, 28 Sep 2026 01:30:00 +0300 Subject: [PATCH] fix(compaction): plugin pin beats configured agent; diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 995a844292d9..37657b2bde82 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -77,7 +77,7 @@ type Dependencies = { // resolver (structural subset of SessionRunnerModel.Interface to avoid an // import cycle with the runner). readonly models?: { - readonly resolveRef?: (ref: string) => Effect.Effect<Model, unknown> | undefined + readonly resolveRef?: (ref: string) => Effect.Effect<Model, unknown> } | undefined } From 98b0ce90c88ab768a27343b96ac6c60a40b0b150 Mon Sep 17 00:00:00 2001 From: "Mohamed R. Khalil" <m.radwan.khalil@gmail.com> Date: Mon, 28 Sep 2026 02:13:16 +0300 Subject: [PATCH 21/24] fix(local-fixes): converge stable-name exe onto the staged build; verify the actually-running binary (launch-hijack hardening) --- local-fixes/pin-opencode.ps1 | 27 +++++++++++++++++++++++++++ local-fixes/verify.mjs | 30 ++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/local-fixes/pin-opencode.ps1 b/local-fixes/pin-opencode.ps1 index af68bf7550d2..e347ec43a849 100644 --- a/local-fixes/pin-opencode.ps1 +++ b/local-fixes/pin-opencode.ps1 @@ -101,6 +101,33 @@ if ($running) { } } +# ---- 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\<pid>.json must record" diff --git a/local-fixes/verify.mjs b/local-fixes/verify.mjs index 91be80df47b8..4553c70bc40c 100644 --- a/local-fixes/verify.mjs +++ b/local-fixes/verify.mjs @@ -8,8 +8,9 @@ * 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 } from "node:path" +import { join, dirname, basename, resolve } from "node:path" import { fileURLToPath } from "node:url" const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..") @@ -95,7 +96,32 @@ for (const fix of fixes) { } } -// 4. fix commit still in this repo +// 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" }) From 79542ac1e9fd1f520932fa178891fa68ace1b511 Mon Sep 17 00:00:00 2001 From: "Mohamed R. Khalil" <m.radwan.khalil@gmail.com> Date: Tue, 6 Oct 2026 20:48:43 +0300 Subject: [PATCH 22/24] 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 cd790839f630..0c87603c1519 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 000000000000..eb996e27ab90 --- /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" <m.radwan.khalil@gmail.com> +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<string, unknown> = {}) { ++ 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 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<typeof assistantMessage.parts> = [[]] + 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 From 122ce9424affc7cd7e790fd97e05af64f07138f9 Mon Sep 17 00:00:00 2001 From: "Mohamed R. Khalil" <m.radwan.khalil@gmail.com> Date: Tue, 6 Oct 2026 20:50:10 +0300 Subject: [PATCH 23/24] docs(local-fixes): fix5 squashed to 79542ac1e9 + patch --- local-fixes/manifest.json | 4 +- .../patches/empty-assistant-guard.patch | 455 +++++++++++++++--- 2 files changed, 391 insertions(+), 68 deletions(-) diff --git a/local-fixes/manifest.json b/local-fixes/manifest.json index 0c87603c1519..2152bb30cd94 100644 --- a/local-fixes/manifest.json +++ b/local-fixes/manifest.json @@ -80,11 +80,11 @@ { "id": "empty-assistant-guard", "title": "Drop assistant messages with no provider content (fix5)", - "commit": "ac73b30f5a", + "commit": "79542ac1e9fd1f520932fa178891fa68ace1b511", "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." + "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": { diff --git a/local-fixes/patches/empty-assistant-guard.patch b/local-fixes/patches/empty-assistant-guard.patch index eb996e27ab90..bc361e14e8a3 100644 --- a/local-fixes/patches/empty-assistant-guard.patch +++ b/local-fixes/patches/empty-assistant-guard.patch @@ -1,121 +1,444 @@ -From ac73b30f5ac97bb243704b358c005f0ec6d43557 Mon Sep 17 00:00:00 2001 +From 79542ac1e9fd1f520932fa178891fa68ace1b511 Mon Sep 17 00:00:00 2001 From: "Mohamed R. Khalil" <m.radwan.khalil@gmail.com> -Date: Tue, 6 Oct 2026 20:25:11 +0300 +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 carrying no content with +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. 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. +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). --- - .../src/session/message-v2.fix5.test.ts | 70 +++++++++++++++++++ - packages/opencode/src/session/message-v2.ts | 14 +++- - 2 files changed, 83 insertions(+), 1 deletion(-) + 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" <m.radwan.khalil@gmail.com> ++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<string, unknown> = {}) { +++ 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..90ddffefed +index 0000000000..01184931e7 --- /dev/null +++ b/packages/opencode/src/session/message-v2.fix5.test.ts -@@ -0,0 +1,70 @@ +@@ -0,0 +1,214 @@ +import { describe, expect, test } from "bun:test" -+import { toModelMessages } from "./message-v2" ++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 -+const model = { -+ providerID: "kimi-for-coding", -+ id: "k3-256k", -+ api: { id: "openai-compatible", npm: "@ai-sdk/openai-compatible" }, -+} as never ++// 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 assistant(parts: any[], info: Record<string, unknown> = {}) { ++function userInfo(id: string): SessionV1.User { + return { -+ info: { -+ id: "msg_" + Math.random().toString(36).slice(2, 10), -+ role: "assistant", -+ providerID: "kimi-for-coding", -+ modelID: "k3-256k", -+ ...info, -+ }, -+ parts, ++ 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}`), + } +} -+function user(text: string) { -+ return { info: { id: "msg_u" + Math.random().toString(36).slice(2, 10), role: "user" }, parts: [{ type: "text", text }] } ++ ++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 toModelMessages([user("hi"), assistant([{ type: "step-start" }]), user("again")], model) -+ const roles = out.map((m: any) => m.role) -+ expect(roles).toEqual(["user", "user"]) ++ 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 toModelMessages([user("hi"), assistant([{ type: "text", text: "" }])], model) ++ 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 toModelMessages([assistant([{ type: "text", text: "hello" }])], model) ++ 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 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) ++ 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("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) ++ 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 toModelMessages([user("hi"), assistant([])], model) ++ 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..80ef17f514 100644 +index 2bd0e51715..e5fc60ea1e 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* ( +@@ -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. 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 ++ // 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-") -+ }) -+ if (hasProviderContent) { ++ } ++ const stepBlocks: Array<typeof assistantMessage.parts> = [[]] ++ 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 From d36674541ea83615b5c2f83281a380d009f85e38 Mon Sep 17 00:00:00 2001 From: "Mohamed R. Khalil" <m.radwan.khalil@gmail.com> Date: Tue, 6 Oct 2026 20:52:41 +0300 Subject: [PATCH 24/24] docs(local-fixes): fix5 cherry-pick commit d6ac80bb70 --- local-fixes/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/local-fixes/manifest.json b/local-fixes/manifest.json index 2152bb30cd94..b8edee49edaa 100644 --- a/local-fixes/manifest.json +++ b/local-fixes/manifest.json @@ -80,7 +80,7 @@ { "id": "empty-assistant-guard", "title": "Drop assistant messages with no provider content (fix5)", - "commit": "79542ac1e9fd1f520932fa178891fa68ace1b511", + "commit": "d6ac80bb70d30ae69dae9bea6fab447910110103", "patch": "local-fixes/patches/empty-assistant-guard.patch", "test": "packages/opencode/src/session/message-v2.fix5.test.ts", "markers": [],