+
Date: Fri, 30 Oct 2026 11:57:58 -0500
Subject: [PATCH 03/19] fix(core): cap compaction's prompt size at the context
window (#60660)
---
packages/core/src/session/compaction.ts | 15 +++++++++++----
packages/core/test/session-compaction.test.ts | 6 ++++++
2 files changed, 17 insertions(+), 4 deletions(-)
diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts
index 49005f93db11..e056b0b2bb73 100644
--- a/packages/core/src/session/compaction.ts
+++ b/packages/core/src/session/compaction.ts
@@ -1133,9 +1133,8 @@ export const estimatePrompt = (context: SessionContext.Loaded): Estimate => {
* to give up `RESERVE_MIN` keeps `RESERVE_SHARE`.
*/
const calculateCeiling = (limit: SessionContext.Loaded["model"]["limit"], buffer: number | undefined) => {
- // Unknown limits are reported as 0. An unknown input limit falls back to the context window; with no window at
- // all, only a provider rejection can limit the request.
- const window = limit.input || limit.context
+ // With no window at all, only a provider rejection can limit the request.
+ const window = inputWindow(limit)
if (window <= 0) return Number.POSITIVE_INFINITY
// A buffer that leaves no room, such as one set for a larger model, is ignored.
if (buffer !== undefined && buffer < window) return window - buffer
@@ -1147,7 +1146,15 @@ const calculateCeiling = (limit: SessionContext.Loaded["model"]["limit"], buffer
* cannot generate that much. Zero for an unknown window.
*/
const summaryRoom = (limit: SessionContext.Loaded["model"]["limit"], output = RESERVE_MIN) =>
- Math.max(0, (limit.input || limit.context) - Math.min(RESERVE_MIN, output))
+ Math.max(0, inputWindow(limit) - Math.min(RESERVE_MIN, output))
+
+/**
+ * The largest prompt the model takes, or 0 when unknown. Unknown limits are reported as 0, and an unknown input limit
+ * falls back to the context window. The context window also holds the reply, so an input limit above it, as some
+ * catalogs and config overrides report, cannot be used in full.
+ */
+const inputWindow = (limit: SessionContext.Loaded["model"]["limit"]) =>
+ limit.input && limit.context ? Math.min(limit.input, limit.context) : limit.input || limit.context
/**
* Another provider's count may describe a native window this model cannot replay. History then holds the original
diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts
index efb047b00727..43d11f472540 100644
--- a/packages/core/test/session-compaction.test.ts
+++ b/packages/core/test/session-compaction.test.ts
@@ -430,6 +430,12 @@ it.effect("auto compaction estimates current content against the buffered prompt
expect(yield* due(native(231_200))).toBe(true)
expect(yield* due(native(1_000_000, { context: 0, input: undefined, output: 0 }))).toBe(false)
+ // An input limit above the context window, as some catalogs and config overrides report, cannot be used in full.
+ const overstated = { context: 128_000, input: 272_000, output: 128_000 }
+ expect(yield* due(input(108_799, overstated))).toBe(false)
+ expect(yield* due(input(108_800, overstated))).toBe(true)
+ expect(yield* due(input(200_000, { context: 0, input: 272_000, output: 0 }))).toBe(false)
+
// The summary's 16k output limit is more than 15% of a 100k window, so it sets the ceiling.
const contextLimited = { context: 100_000, output: 10_000 }
expect(yield* due(input(83_999, contextLimited))).toBe(false)
From 872f694b05a636c51b7a6ec718fa65afef51ed32 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Fri, 30 Oct 2026 13:06:06 -0400
Subject: [PATCH 04/19] release: v2.2.4 (#60664)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
---
CHANGELOG.md | 60 ++++++++++++++++++++++++
bun.lock | 66 +++++++++++++--------------
package.json | 2 +-
packages/ai/package.json | 2 +-
packages/app/package.json | 2 +-
packages/cli/package.json | 2 +-
packages/client/package.json | 2 +-
packages/codemode/package.json | 2 +-
packages/console/app/package.json | 2 +-
packages/core/package.json | 2 +-
packages/desktop/package.json | 2 +-
packages/function/package.json | 2 +-
packages/gui-plugins/package.json | 2 +-
packages/http-recorder/package.json | 2 +-
packages/httpapi-codegen/package.json | 2 +-
packages/latex/package.json | 2 +-
packages/merman/package.json | 2 +-
packages/patchmark/package.json | 2 +-
packages/plugin-browser/package.json | 2 +-
packages/plugin/package.json | 2 +-
packages/plugins/figma/package.json | 2 +-
packages/protocol/package.json | 2 +-
packages/schema/package.json | 2 +-
packages/sdk/package.json | 2 +-
packages/server/package.json | 2 +-
packages/session-ui/package.json | 2 +-
packages/simulation/package.json | 2 +-
packages/stats/app/package.json | 2 +-
packages/stats/core/package.json | 2 +-
packages/stats/server/package.json | 2 +-
packages/theme/package.json | 2 +-
packages/tui/package.json | 2 +-
packages/ui/package.json | 2 +-
packages/util/package.json | 2 +-
packages/web/package.json | 2 +-
sdks/vscode/package.json | 2 +-
services/update/package.json | 2 +-
37 files changed, 128 insertions(+), 68 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e035452d8c36..878150f2bfdb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,66 @@
Release notes for OpenCode V2, newest first.
+## v2.2.4 — 2026-10-30
+
+### Highlights
+
+- **Persistent Desktop Terminals**: Desktop terminals now belong to the session, survive server restarts with their scrollback, and stay synchronized with the TUI and other windows. Renamed terminals also keep their names across restarts. ([#60346](https://github.com/anomalyco/opencode/pull/60346))
+- **Publish Interactive Artifacts**: Enable `"experimental": { "artifacts": true }` in `opencode.json` to let agents publish, update, and serve HTML or static artifacts through the built-in local provider or plugin-provided storage. ([#60351](https://github.com/anomalyco/opencode/pull/60351))
+- **Guided Claude Agent Setup**: Select **Claude Agent** from the provider picker to copy `claude auth login`, install Claude Code, and verify the connection before continuing to model selection. ([#60603](https://github.com/anomalyco/opencode/pull/60603))
+- **Choose a Base Branch Quickly**: While creating a worktree, open **Choose base branch** with `⌘⌥.` on macOS or `Ctrl+Alt+.` on Windows and Linux. The command is also available in the command palette and Settings › Shortcuts. ([#60579](https://github.com/anomalyco/opencode/pull/60579))
+
+### Core
+
+#### Improvements
+
+- Added opt-in artifacts with `artifact_create`, `artifact_read`, `artifact_update`, and `artifact_delete`, including a built-in local static-file provider. ([#60351](https://github.com/anomalyco/opencode/pull/60351))
+- Made enabled schedules run under `opencode serve` and coordinated multiple server processes so a schedule fires only once. Schedules remain opt-in through `experimental.schedules`. ([#60478](https://github.com/anomalyco/opencode/pull/60478))
+- Raised OpenCode Go limits for DeepSeek V4.1 Flash. ([#60625](https://github.com/anomalyco/opencode/pull/60625))
+
+#### Bug fixes
+
+- Allowed user, project, and remote configuration to override Console-supplied provider names, limits, variants, settings, and headers. ([#60597](https://github.com/anomalyco/opencode/issues/60597))
+- Kept local OpenCode service requests out of environment-configured proxies. ([#60441](https://github.com/anomalyco/opencode/pull/60441))
+- Replaced Claude Code’s misleading `/login` instruction with `claude auth login`. Thanks [@Tarquinen](https://github.com/Tarquinen). ([#60592](https://github.com/anomalyco/opencode/pull/60592))
+
+### TUI
+
+#### Bug fixes
+
+- Preserved prompt cursor styles applied by plugins when themes or unrelated prompt state change. ([#60599](https://github.com/anomalyco/opencode/pull/60599))
+- Added missing right padding to edit and patch diffs. ([#60614](https://github.com/anomalyco/opencode/pull/60614))
+- Extended the vertical session-tab activity pulse across the full tab width. ([#60338](https://github.com/anomalyco/opencode/pull/60338))
+
+### Desktop
+
+#### Improvements
+
+- Moved terminals to persistent session PTYs shared with the TUI and other windows, with restored scrollback, server-side renaming, and restart persistence. ([#60346](https://github.com/anomalyco/opencode/pull/60346))
+- Added guided Claude Agent installation, sign-in, connection checking, and recovery from **Needs sign-in**. ([#60603](https://github.com/anomalyco/opencode/pull/60603))
+- Added **Choose base branch** to the command palette and shortcuts, bound to `⌘⌥.` or `Ctrl+Alt+.`. ([#60579](https://github.com/anomalyco/opencode/pull/60579))
+- Simplified the session location popover and made long paths easier to read. ([#60492](https://github.com/anomalyco/opencode/pull/60492))
+
+#### Bug fixes
+
+- Kept terminal tab clicks and rename fields attached to the intended tab. ([#60645](https://github.com/anomalyco/opencode/pull/60645))
+- Listed matching commands before sessions in command-palette search. ([#60616](https://github.com/anomalyco/opencode/pull/60616))
+- Restored copying user messages from an open actions menu. ([#60576](https://github.com/anomalyco/opencode/pull/60576))
+- Reduced repeated work and allocations while streaming Markdown, especially in long sessions. ([#60549](https://github.com/anomalyco/opencode/pull/60549), [#60562](https://github.com/anomalyco/opencode/pull/60562), [#60577](https://github.com/anomalyco/opencode/pull/60577))
+
+### SDK
+
+#### Improvements
+
+- Added `client.experimental.artifacts.providers`, `list`, `create`, `get`, `update`, `delete`, and `view`. ([#60351](https://github.com/anomalyco/opencode/pull/60351))
+- Extended `client.experimental.persistentPty.update` to rename terminals or clear a user title, and exposed `userTitle` plus `persistent-pty.updated` events. ([#60329](https://github.com/anomalyco/opencode/pull/60329))
+
+### Extensions
+
+#### Improvements
+
+- Added `ctx.experimental.artifact.transform(...)` to Effect and Promise plugins for registering artifact providers, capabilities, prompts, and the default provider. ([#60351](https://github.com/anomalyco/opencode/pull/60351))
+
## v2.2.3 — 2026-10-30
### Highlights
diff --git a/bun.lock b/bun.lock
index 0e14bce8a3bb..4a0b6be1d7b7 100644
--- a/bun.lock
+++ b/bun.lock
@@ -29,7 +29,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -51,7 +51,7 @@
},
"packages/app": {
"name": "@opencode/app",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -105,7 +105,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
- "version": "2.2.3",
+ "version": "2.2.4",
"bin": {
"opencode": "./bin/opencode.cjs",
"opencode2": "./bin/opencode2.cjs",
@@ -170,7 +170,7 @@
},
"packages/client": {
"name": "@opencode/client",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -196,7 +196,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -209,7 +209,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -247,7 +247,7 @@
},
"packages/core": {
"name": "@opencode/core",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"@ai-sdk/provider": "3.0.19",
"@ai-sdk/provider-utils": "4.0.62",
@@ -314,7 +314,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "5.0.0",
@@ -361,7 +361,7 @@
},
"packages/function": {
"name": "@opencode/function",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -377,7 +377,7 @@
},
"packages/gui-plugins": {
"name": "@opencode/gui-plugins",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"@betteroffice/docx": "0.4.3",
"@betteroffice/fonts": "0.4.0",
@@ -422,7 +422,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"@effect/platform-node-shared": "catalog:",
},
@@ -441,7 +441,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -454,7 +454,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -468,7 +468,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -483,7 +483,7 @@
},
"packages/patchmark": {
"name": "@opencode/patchmark",
- "version": "2.2.3",
+ "version": "2.2.4",
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
@@ -496,7 +496,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -541,7 +541,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -556,7 +556,7 @@
},
"packages/plugins/figma": {
"name": "@opencode/plugin-figma",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"gray-matter": "4.0.3",
},
@@ -586,7 +586,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -601,7 +601,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -625,7 +625,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -646,7 +646,7 @@
},
"packages/server": {
"name": "@opencode/server",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -669,7 +669,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"@kitlangton/solid-motion": "0.2.2",
"@kobalte/core": "catalog:",
@@ -701,7 +701,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -721,7 +721,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -747,7 +747,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"@planetscale/database": "1.19.0",
"drizzle-orm": "catalog:",
@@ -765,7 +765,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"@effect/platform-node": "catalog:",
"@opencode/stats-core": "workspace:*",
@@ -800,7 +800,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -814,7 +814,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -846,7 +846,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"@kitlangton/solid-motion": "0.2.2",
"@kobalte/core": "catalog:",
@@ -881,7 +881,7 @@
},
"packages/util": {
"name": "@opencode/util",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -920,7 +920,7 @@
},
"packages/web": {
"name": "@opencode/web",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"@astrojs/cloudflare": "14.1.4",
"@astrojs/markdown-remark": "7.3.2",
@@ -972,7 +972,7 @@
},
"services/update": {
"name": "@opencode/update",
- "version": "2.2.3",
+ "version": "2.2.4",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
diff --git a/package.json b/package.json
index 51885ce480fe..f859e99d29be 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
- "version": "2.2.3",
+ "version": "2.2.4",
"private": true,
"type": "module",
"packageManager": "bun@1.4.4",
diff --git a/packages/ai/package.json b/packages/ai/package.json
index e2d4b7c0c8b5..c9354d71b25e 100644
--- a/packages/ai/package.json
+++ b/packages/ai/package.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
- "version": "2.2.3",
+ "version": "2.2.4",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
diff --git a/packages/app/package.json b/packages/app/package.json
index a1dd84a51b60..cd67034c4541 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
- "version": "2.2.3",
+ "version": "2.2.4",
"description": "",
"type": "module",
"exports": {
diff --git a/packages/cli/package.json b/packages/cli/package.json
index b5d3786e8458..8ab23ac157af 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
- "version": "2.2.3",
+ "version": "2.2.4",
"type": "module",
"license": "MIT",
"bin": {
diff --git a/packages/client/package.json b/packages/client/package.json
index 2d64f7c006b5..e8af55ef2a3b 100644
--- a/packages/client/package.json
+++ b/packages/client/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
- "version": "2.2.3",
+ "version": "2.2.4",
"type": "module",
"license": "MIT",
"repository": {
diff --git a/packages/codemode/package.json b/packages/codemode/package.json
index 03e1686c4c44..ca6e26d37846 100644
--- a/packages/codemode/package.json
+++ b/packages/codemode/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
- "version": "2.2.3",
+ "version": "2.2.4",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
diff --git a/packages/console/app/package.json b/packages/console/app/package.json
index 4d8f78155414..6449307762a8 100644
--- a/packages/console/app/package.json
+++ b/packages/console/app/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
- "version": "2.2.3",
+ "version": "2.2.4",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/core/package.json b/packages/core/package.json
index 4c06a40d6ebc..695a2061f492 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
- "version": "2.2.3",
+ "version": "2.2.4",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index 65fb7cf13a45..cc04de40f397 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "@opencode/desktop",
"private": true,
- "version": "2.2.3",
+ "version": "2.2.4",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
diff --git a/packages/function/package.json b/packages/function/package.json
index 9bea876f061e..a367c48ca1ae 100644
--- a/packages/function/package.json
+++ b/packages/function/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
- "version": "2.2.3",
+ "version": "2.2.4",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
diff --git a/packages/gui-plugins/package.json b/packages/gui-plugins/package.json
index fe87dbcd14d5..8ffa8de65412 100644
--- a/packages/gui-plugins/package.json
+++ b/packages/gui-plugins/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/gui-plugins",
- "version": "2.2.3",
+ "version": "2.2.4",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json
index a1d8c87d7ca6..8827af7f407d 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": "2.2.3",
+ "version": "2.2.4",
"name": "@opencode/http-recorder",
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
"type": "module",
diff --git a/packages/httpapi-codegen/package.json b/packages/httpapi-codegen/package.json
index 59381536a119..853f47d196ec 100644
--- a/packages/httpapi-codegen/package.json
+++ b/packages/httpapi-codegen/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/httpapi-codegen",
- "version": "2.2.3",
+ "version": "2.2.4",
"private": true,
"type": "module",
"exports": {
diff --git a/packages/latex/package.json b/packages/latex/package.json
index b24d0188f3bd..0ed89093e6b9 100644
--- a/packages/latex/package.json
+++ b/packages/latex/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/latex",
- "version": "2.2.3",
+ "version": "2.2.4",
"private": true,
"type": "module",
"exports": {
diff --git a/packages/merman/package.json b/packages/merman/package.json
index cb51756abc63..35ef417872a4 100644
--- a/packages/merman/package.json
+++ b/packages/merman/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/merman",
- "version": "2.2.3",
+ "version": "2.2.4",
"private": true,
"type": "module",
"exports": {
diff --git a/packages/patchmark/package.json b/packages/patchmark/package.json
index 0396ced11734..14d6943f9e53 100644
--- a/packages/patchmark/package.json
+++ b/packages/patchmark/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/patchmark",
- "version": "2.2.3",
+ "version": "2.2.4",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/plugin-browser/package.json b/packages/plugin-browser/package.json
index e8aaa0680fee..28d41384d096 100644
--- a/packages/plugin-browser/package.json
+++ b/packages/plugin-browser/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin-browser",
- "version": "2.2.3",
+ "version": "2.2.4",
"description": "OpenCode's desktop browser plugin",
"type": "module",
"license": "MIT",
diff --git a/packages/plugin/package.json b/packages/plugin/package.json
index 0b41e5c2af78..56b29da17a62 100644
--- a/packages/plugin/package.json
+++ b/packages/plugin/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin",
- "version": "2.2.3",
+ "version": "2.2.4",
"type": "module",
"license": "MIT",
"scripts": {
diff --git a/packages/plugins/figma/package.json b/packages/plugins/figma/package.json
index 0d1b78237a40..f64c5e4ee7ff 100644
--- a/packages/plugins/figma/package.json
+++ b/packages/plugins/figma/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin-figma",
- "version": "2.2.3",
+ "version": "2.2.4",
"description": "Figma MCP integration and official Figma skills for OpenCode",
"type": "module",
"license": "MIT",
diff --git a/packages/protocol/package.json b/packages/protocol/package.json
index 7da1496aa2fa..0493a9e06d76 100644
--- a/packages/protocol/package.json
+++ b/packages/protocol/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/protocol",
- "version": "2.2.3",
+ "version": "2.2.4",
"type": "module",
"license": "MIT",
"repository": {
diff --git a/packages/schema/package.json b/packages/schema/package.json
index b4fe0508e4c0..8f4adbb00b96 100644
--- a/packages/schema/package.json
+++ b/packages/schema/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/schema",
- "version": "2.2.3",
+ "version": "2.2.4",
"type": "module",
"license": "MIT",
"repository": {
diff --git a/packages/sdk/package.json b/packages/sdk/package.json
index ac6b54454c48..be674868d2ad 100644
--- a/packages/sdk/package.json
+++ b/packages/sdk/package.json
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
- "version": "2.2.3",
+ "version": "2.2.4",
"name": "@opencode/sdk",
"type": "module",
"license": "MIT",
diff --git a/packages/server/package.json b/packages/server/package.json
index 5d2873ff533c..bf94847e3639 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/server",
- "version": "2.2.3",
+ "version": "2.2.4",
"type": "module",
"license": "MIT",
"repository": {
diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json
index fdd576f3936d..ca6986620de5 100644
--- a/packages/session-ui/package.json
+++ b/packages/session-ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode/session-ui",
- "version": "2.2.3",
+ "version": "2.2.4",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/simulation/package.json b/packages/simulation/package.json
index 65d1b9c6f14a..29988f29a9a9 100644
--- a/packages/simulation/package.json
+++ b/packages/simulation/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/simulation",
- "version": "2.2.3",
+ "version": "2.2.4",
"type": "module",
"license": "MIT",
"repository": {
diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json
index 489a7bb2c00d..f13399941c76 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/stats-app",
- "version": "2.2.3",
+ "version": "2.2.4",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json
index 9a6363209f58..dec98c188463 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/stats-core",
- "version": "2.2.3",
+ "version": "2.2.4",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json
index c2f979b43cb0..dd66af3fd396 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/stats-server",
- "version": "2.2.3",
+ "version": "2.2.4",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/theme/package.json b/packages/theme/package.json
index a3f1faf3b02f..30753a9c9ce0 100644
--- a/packages/theme/package.json
+++ b/packages/theme/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/theme",
- "version": "2.2.3",
+ "version": "2.2.4",
"type": "module",
"license": "MIT",
"repository": {
diff --git a/packages/tui/package.json b/packages/tui/package.json
index a88a4fbd0251..95d5fda6853f 100644
--- a/packages/tui/package.json
+++ b/packages/tui/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/tui",
- "version": "2.2.3",
+ "version": "2.2.4",
"private": true,
"type": "module",
"license": "MIT",
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 2027ec65ac2e..7eef2f122bdc 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode/ui",
- "version": "2.2.3",
+ "version": "2.2.4",
"type": "module",
"license": "MIT",
"repository": {
diff --git a/packages/util/package.json b/packages/util/package.json
index abd7cdd0f2ff..a89d6816d2e9 100644
--- a/packages/util/package.json
+++ b/packages/util/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/util",
- "version": "2.2.3",
+ "version": "2.2.4",
"type": "module",
"license": "MIT",
"repository": {
diff --git a/packages/web/package.json b/packages/web/package.json
index 9d9c86485b09..d192d57ab993 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -2,7 +2,7 @@
"name": "@opencode/web",
"type": "module",
"license": "MIT",
- "version": "2.2.3",
+ "version": "2.2.4",
"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 089857409dc5..fd791cfb6d3d 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": "2.2.3",
+ "version": "2.2.4",
"publisher": "sst-dev",
"repository": {
"type": "git",
diff --git a/services/update/package.json b/services/update/package.json
index ee1e610509fd..1b112ba1cef8 100644
--- a/services/update/package.json
+++ b/services/update/package.json
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/update",
- "version": "2.2.3",
+ "version": "2.2.4",
"private": true,
"type": "module",
"scripts": {
From 822594b9ae6d1e8778dda79cf7a7b84d0f9f45f3 Mon Sep 17 00:00:00 2001
From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Date: Fri, 30 Oct 2026 12:28:20 -0500
Subject: [PATCH 05/19] fix(ai): stop demoting unsigned and interrupted
reasoning to text (#60498)
---
.../ai/src/protocols/anthropic-messages.ts | 13 +---
packages/ai/src/protocols/bedrock-converse.ts | 7 +-
packages/ai/src/protocols/gemini.ts | 18 +----
.../ai/src/protocols/google-interactions.ts | 10 ++-
packages/ai/src/protocols/mistral-chat.ts | 14 ++--
packages/ai/src/providers/meta.ts | 1 -
packages/ai/src/providers/minimax.ts | 5 +-
.../test/provider/anthropic-messages.test.ts | 34 ++++-----
.../provider/bedrock-converse-cache.test.ts | 2 +-
.../ai/test/provider/bedrock-converse.test.ts | 18 +++--
packages/ai/test/provider/gemini.test.ts | 72 ++++++++++++++++---
.../google-vertex-interactions.test.ts | 43 +++++++++++
.../ai/test/provider/mistral-chat.test.ts | 18 ++++-
.../core/src/session/runner/to-llm-message.ts | 8 ++-
.../core/test/session-runner-message.test.ts | 7 +-
packages/core/test/session-runner.test.ts | 19 ++---
16 files changed, 185 insertions(+), 104 deletions(-)
diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts
index 1a6bb5af33b4..ef1f19b70313 100644
--- a/packages/ai/src/protocols/anthropic-messages.ts
+++ b/packages/ai/src/protocols/anthropic-messages.ts
@@ -843,6 +843,7 @@ const requireThinkingSignature = (request: LLMRequest) => {
)
return false
if (provider.includes("xiaomi") || model.includes("mimo") || baseURL.includes("xiaomimimo.com")) return false
+ if (model.startsWith("qwen") || model.startsWith("minimax-")) return false
return true
}
@@ -971,19 +972,9 @@ const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest, breakpoi
continue
}
if (!signature || signature.trim().length === 0) {
- if (part.text.trim().length === 0) continue
- if (!requireThinkingSignature(request)) {
+ if (part.text.trim().length > 0 && !requireThinkingSignature(request)) {
content.push({ type: "thinking", thinking: part.text, signature: "" })
- continue
}
- // Without a signature this cannot be a valid thinking block per
- // the SDK ThinkingBlockParam:3217 — demote to text so the
- // conversation remains sendable.
- content.push({
- type: "text",
- text: part.text,
- cache_control: cacheControl(breakpoints, part.cache),
- })
continue
}
content.push({ type: "thinking", thinking: part.text, signature })
diff --git a/packages/ai/src/protocols/bedrock-converse.ts b/packages/ai/src/protocols/bedrock-converse.ts
index e86a77334ae3..353f2b09d6e4 100644
--- a/packages/ai/src/protocols/bedrock-converse.ts
+++ b/packages/ai/src/protocols/bedrock-converse.ts
@@ -427,12 +427,7 @@ const lowerMessages = Effect.fnUntraced(function* (
content.push({ reasoningContent: { redactedContent: redactedData } })
continue
}
- if (signature === undefined || signature.trim().length === 0) {
- // Interrupted streams and model switches can leave unsigned reasoning.
- // Preserve readable history as text rather than replay invalid reasoningContent.
- if (part.text.trim().length > 0) content.push(...textWithCache(breakpoints, part.text, part.cache))
- continue
- }
+ if (signature === undefined || signature.trim().length === 0) continue
content.push({ reasoningContent: { reasoningText: { text: part.text, signature } } })
continue
}
diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts
index 29218420003f..be9e929ca208 100644
--- a/packages/ai/src/protocols/gemini.ts
+++ b/packages/ai/src/protocols/gemini.ts
@@ -32,22 +32,12 @@ import { Lifecycle } from "./utils/lifecycle.js"
const ADAPTER = "gemini"
const CONTINUATION = "CONTINUATION"
-// Google documents this sentinel for replaying Gemini 3 function calls after their original signature was lost.
+// Google documents this sentinel for replaying function calls after their original signature was lost.
const SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator"
// Gemini 2.5 rejects a budget under the model's minimum: 512 on Flash-Lite, the highest, and 128 on Pro.
const MIN_THINKING_BUDGET = 512
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
-// Gemini 3 rejects replayed function calls without a thought signature. Google's SDKs avoid that in normal chats by
-// retaining complete model responses, but OpenCode reconstructs durable history and may encounter an unsigned call
-// from an older or external session. Model IDs are open-ended, so unknown Gemini aliases inherit current behavior.
-const requiresThoughtSignatureFallback = (modelID: string) => {
- if (!/(^|\/)gemini-/i.test(modelID)) return false
- if (/(^|\/)gemini-(?:1|2)(?:[.-]|$)/i.test(modelID)) return false
- if (/(^|\/)gemini-pro(?:-vision)?$/i.test(modelID)) return false
- return !/(^|\/)gemini-robotics-er-1\.5(?:[.-]|$)/i.test(modelID)
-}
-
// Gemini 3 accepts media nested inside function responses; matched Gemini 2.5 variants reject it,
// so their tool-result attachments lower as a separate user turn instead.
const routesLegacyToolMedia = (modelID: string) => /gemini-2[.-]5(?:[.-]|$)/i.test(modelID)
@@ -390,11 +380,7 @@ const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest) {
const signature = lowered.thoughtSignature
parts.push({
...lowered,
- thoughtSignature:
- signature ??
- (requiresThoughtSignatureFallback(request.model.id) && !hasSignedToolCall
- ? SKIP_THOUGHT_SIGNATURE_VALIDATOR
- : undefined),
+ thoughtSignature: signature ?? (!hasSignedToolCall ? SKIP_THOUGHT_SIGNATURE_VALIDATOR : undefined),
})
if (signature !== undefined) hasSignedToolCall = true
continue
diff --git a/packages/ai/src/protocols/google-interactions.ts b/packages/ai/src/protocols/google-interactions.ts
index a04083fd0783..24d2179afb19 100644
--- a/packages/ai/src/protocols/google-interactions.ts
+++ b/packages/ai/src/protocols/google-interactions.ts
@@ -23,6 +23,7 @@ import { GeminiGenerateContent } from "./utils/gemini-generate-content.js"
import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "google-interactions"
+const SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator"
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
// =============================================================================
@@ -259,6 +260,7 @@ const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest) {
steps[steps.length - 1] = { type, content: [...last.content, content] }
else steps.push({ type, content: [content] })
}
+ let hasSignedStep = false
for (const part of message.content) {
if (message.role === "tool") {
if (part.type !== "tool-result")
@@ -281,21 +283,25 @@ const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest) {
continue
}
if (message.role === "assistant" && part.type === "reasoning") {
+ const thoughtSignature = signature(part.providerMetadata, key)
steps.push({
type: "thought",
- signature: signature(part.providerMetadata, key),
+ signature: thoughtSignature,
summary: part.text ? [{ type: "text", text: part.text }] : undefined,
})
+ if (thoughtSignature !== undefined) hasSignedStep = true
continue
}
if (message.role === "assistant" && part.type === "tool-call") {
+ const toolSignature = signature(part.providerMetadata, key)
steps.push({
type: "function_call",
id: part.id,
name: part.name,
arguments: part.input,
- signature: signature(part.providerMetadata, key),
+ signature: toolSignature ?? (!hasSignedStep ? SKIP_THOUGHT_SIGNATURE_VALIDATOR : undefined),
})
+ if (toolSignature !== undefined) hasSignedStep = true
continue
}
return yield* ProviderShared.unsupportedContent(
diff --git a/packages/ai/src/protocols/mistral-chat.ts b/packages/ai/src/protocols/mistral-chat.ts
index 619f169d2a2a..4c1fbfbab8f1 100644
--- a/packages/ai/src/protocols/mistral-chat.ts
+++ b/packages/ai/src/protocols/mistral-chat.ts
@@ -254,7 +254,6 @@ const lowerToolCall = (part: ToolCallPart, normalizeID: (id: string) => string):
const lowerAssistant = Effect.fnUntraced(function* (
message: LLMRequest["messages"][number],
normalizeID: (id: string) => string,
- prefix: boolean,
) {
const structured = message.content.some(
(part) => part.type === "reasoning" && isMistralThinkingContent(part.providerMetadata?.mistral?.thinking),
@@ -271,8 +270,6 @@ const lowerAssistant = Effect.fnUntraced(function* (
if (part.type === "reasoning") {
const native = part.providerMetadata?.mistral?.thinking
if (structured && isMistralThinkingContent(native)) content.push(native)
- else if (structured) content.push({ type: "text", text: part.text })
- else text.push(part.text)
continue
}
if (part.type === "tool-call") {
@@ -285,7 +282,6 @@ const lowerAssistant = Effect.fnUntraced(function* (
role: "assistant" as const,
content: structured ? content : text.join(""),
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
- ...(prefix ? { prefix: true as const } : {}),
}
})
@@ -358,15 +354,17 @@ const lowerMessages = Effect.fnUntraced(function* (request: LLMRequest) {
const hasNativeThinking = message.content.some(
(part) => part.type === "reasoning" && isMistralThinkingContent(part.providerMetadata?.mistral?.thinking),
)
- const text = message.content
- .flatMap((part) => (part.type === "text" || part.type === "reasoning" ? [part.text] : []))
- .join("")
+ const text = message.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("")
if (!hasToolCalls && !hasNativeThinking && text.trim() === "") continue
- messages.push(yield* lowerAssistant(message, normalizeID, !hasToolCalls && message === request.messages.at(-1)))
+ messages.push(yield* lowerAssistant(message, normalizeID))
continue
}
messages.push(...(yield* lowerToolResults(message, normalizeID)))
}
+ const trailing = messages.at(-1)
+ if (trailing?.role === "assistant" && !trailing.tool_calls) {
+ messages[messages.length - 1] = { ...trailing, prefix: true }
+ }
return messages
})
diff --git a/packages/ai/src/providers/meta.ts b/packages/ai/src/providers/meta.ts
index eecbb8125479..13fac64f3b4f 100644
--- a/packages/ai/src/providers/meta.ts
+++ b/packages/ai/src/providers/meta.ts
@@ -138,7 +138,6 @@ export const configure = (input: Config = {}) => {
const messages = (modelID: string | ModelID) =>
configuredMessages.model({
id: modelID,
- compatibility: { requireSignature: false },
})
const media = MediaRoute.deployment(input, options.auth)
const image = (modelID: string | ModelID) => MetaImages.model({ ...media, id: modelID })
diff --git a/packages/ai/src/providers/minimax.ts b/packages/ai/src/providers/minimax.ts
index 18c69016199c..ec5d77e7333b 100644
--- a/packages/ai/src/providers/minimax.ts
+++ b/packages/ai/src/providers/minimax.ts
@@ -117,7 +117,10 @@ export const configure = (input: Config = {}) => {
auth,
}
const messages = (modelID: string | ModelID) =>
- messagesRoute.with(defaults).model({ id: modelID })
+ messagesRoute.with(defaults).model({
+ id: modelID,
+ compatibility: { requireSignature: false },
+ })
const chat = (modelID: string | ModelID) =>
chatRoute.with(defaults).model({
id: modelID,
diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts
index 41dcc6decb5f..0ba341755951 100644
--- a/packages/ai/test/provider/anthropic-messages.test.ts
+++ b/packages/ai/test/provider/anthropic-messages.test.ts
@@ -1035,13 +1035,11 @@ describe("Anthropic Messages route", () => {
}),
)
- it.effect("demotes unsigned reasoning when signatures are required", () =>
+ it.effect("drops unsigned reasoning when signatures are required", () =>
Effect.gen(function* () {
const prepared = yield* compileUnsignedReasoning(model)
- expect(prepared.body.messages).toEqual([
- { role: "assistant", content: [{ type: "text", text: "unsigned reasoning" }] },
- ])
+ expect(prepared.body.messages).toEqual([])
}),
)
@@ -1061,19 +1059,19 @@ describe("Anthropic Messages route", () => {
.model({ id: "kimi-k2.6" })
const codingPrepared = yield* compileUnsignedReasoning(coding.model({ id: "k3" }))
const moonshotPrepared = yield* compileUnsignedReasoning(moonshot)
+ const qwenPrepared = yield* compileUnsignedReasoning(coding.with({ provider: "opencode" }).model({ id: "qwen3.8-flash" }))
+ const minimaxPrepared = yield* compileUnsignedReasoning(
+ coding.with({ provider: "opencode-go" }).model({ id: "minimax-m3" }),
+ )
- expect(codingPrepared.body.messages).toEqual([
- {
- role: "assistant",
- content: [{ type: "thinking", thinking: "unsigned reasoning", signature: "" }],
- },
- ])
- expect(moonshotPrepared.body.messages).toEqual([
- {
- role: "assistant",
- content: [{ type: "thinking", thinking: "unsigned reasoning", signature: "" }],
- },
- ])
+ for (const prepared of [codingPrepared, moonshotPrepared, qwenPrepared, minimaxPrepared]) {
+ expect(prepared.body.messages).toEqual([
+ {
+ role: "assistant",
+ content: [{ type: "thinking", thinking: "unsigned reasoning", signature: "" }],
+ },
+ ])
+ }
}),
)
@@ -1088,9 +1086,7 @@ describe("Anthropic Messages route", () => {
.model({ id: "k3", compatibility: { requireSignature: true } })
const prepared = yield* compileUnsignedReasoning(compatible)
- expect(prepared.body.messages).toEqual([
- { role: "assistant", content: [{ type: "text", text: "unsigned reasoning" }] },
- ])
+ expect(prepared.body.messages).toEqual([])
}),
)
diff --git a/packages/ai/test/provider/bedrock-converse-cache.test.ts b/packages/ai/test/provider/bedrock-converse-cache.test.ts
index 3ec178fdcec5..263ebd3b7772 100644
--- a/packages/ai/test/provider/bedrock-converse-cache.test.ts
+++ b/packages/ai/test/provider/bedrock-converse-cache.test.ts
@@ -61,7 +61,7 @@ describe("Bedrock Converse cache policy", () => {
{ role: "user", content: [{ text: "Question" }, { text: "\nUpdate\n" }] },
{
role: "assistant",
- content: [{ text: "Answer" }, { text: "Unsigned reasoning" }, { toolUse: { name: "lookup" } }],
+ content: [{ text: "Answer" }, { toolUse: { name: "lookup" } }],
},
{ role: "user", content: [{ toolResult: { content: [{ json: "Result" }] } }] },
],
diff --git a/packages/ai/test/provider/bedrock-converse.test.ts b/packages/ai/test/provider/bedrock-converse.test.ts
index 99e0e72e17b0..8464b9137930 100644
--- a/packages/ai/test/provider/bedrock-converse.test.ts
+++ b/packages/ai/test/provider/bedrock-converse.test.ts
@@ -1197,7 +1197,7 @@ describe("Bedrock Converse route", () => {
for (const signature of [undefined, "", " "]) {
for (const cache of ["none", "auto"] as const) {
- it.effect(`demotes unsigned reasoning to text (${JSON.stringify(signature)}, cache: ${cache})`, () =>
+ it.effect(`drops unsigned reasoning (${JSON.stringify(signature)}, cache: ${cache})`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
ai.request({
@@ -1217,10 +1217,15 @@ describe("Bedrock Converse route", () => {
cache,
}),
)
- expect(prepared.body.messages[1]).toEqual({
- role: "assistant",
- content: [{ text: "Partial thought" }, { cachePoint: { type: "default" } }],
- })
+ expect(prepared.body.messages).toEqual([
+ {
+ role: "user",
+ content:
+ cache === "auto"
+ ? [{ text: "Think" }, { text: "Continue" }, { cachePoint: { type: "default" } }]
+ : [{ text: "Think" }, { text: "Continue" }],
+ },
+ ])
}),
)
}
@@ -1252,7 +1257,7 @@ describe("Bedrock Converse route", () => {
}),
)
- it.effect("demotes foreign reasoning while preserving signed, redacted, text, and tool blocks", () =>
+ it.effect("drops foreign reasoning while preserving signed, redacted, text, and tool blocks", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest(
@@ -1281,7 +1286,6 @@ describe("Bedrock Converse route", () => {
{
role: "assistant",
content: [
- { text: "Foreign thought" },
{ reasoningContent: { reasoningText: { text: "Signed thought", signature: "sig_1" } } },
{ reasoningContent: { reasoningText: { text: "", signature: "sig_2" } } },
{ reasoningContent: { redactedContent: "cmVkYWN0ZWQ=" } },
diff --git a/packages/ai/test/provider/gemini.test.ts b/packages/ai/test/provider/gemini.test.ts
index 22bda497745f..7a0bd6e57afc 100644
--- a/packages/ai/test/provider/gemini.test.ts
+++ b/packages/ai/test/provider/gemini.test.ts
@@ -175,7 +175,12 @@ describe("Gemini route", () => {
expect(prepared.body.contents).toEqual([
{
role: "model",
- parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
+ parts: [
+ {
+ functionCall: { name: "lookup", args: { query: "weather" } },
+ thoughtSignature: "skip_thought_signature_validator",
+ },
+ ],
},
{
role: "user",
@@ -219,8 +224,14 @@ describe("Gemini route", () => {
{
role: "model",
parts: [
- { functionCall: { name: "lookup", args: { query: "weather" } } },
- { functionCall: { name: "lookup", args: { query: "time" } } },
+ {
+ functionCall: { name: "lookup", args: { query: "weather" } },
+ thoughtSignature: "skip_thought_signature_validator",
+ },
+ {
+ functionCall: { name: "lookup", args: { query: "time" } },
+ thoughtSignature: "skip_thought_signature_validator",
+ },
],
},
{
@@ -302,7 +313,15 @@ describe("Gemini route", () => {
)
expect(legacy.body.contents).toEqual([
- { role: "model", parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }] },
+ {
+ role: "model",
+ parts: [
+ {
+ functionCall: { name: "lookup", args: { query: "weather" } },
+ thoughtSignature: "skip_thought_signature_validator",
+ },
+ ],
+ },
{
role: "user",
parts: [{ functionResponse: { name: "lookup", response: { name: "lookup", content: "done" } } }],
@@ -331,7 +350,15 @@ describe("Gemini route", () => {
)
expect(prepared.body.contents).toEqual([
- { role: "model", parts: [{ functionCall: { id: "call_1", name: "lookup", args: { query: "weather" } } }] },
+ {
+ role: "model",
+ parts: [
+ {
+ functionCall: { id: "call_1", name: "lookup", args: { query: "weather" } },
+ thoughtSignature: "skip_thought_signature_validator",
+ },
+ ],
+ },
{
role: "user",
parts: [
@@ -380,7 +407,12 @@ describe("Gemini route", () => {
},
{
role: "model",
- parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
+ parts: [
+ {
+ functionCall: { name: "lookup", args: { query: "weather" } },
+ thoughtSignature: "skip_thought_signature_validator",
+ },
+ ],
},
{
role: "user",
@@ -434,7 +466,15 @@ describe("Gemini route", () => {
)
expect(prepared.body.contents).toEqual([
- { role: "model", parts: [{ functionCall: { name: "read", args: { path: "pixel.png" } } }] },
+ {
+ role: "model",
+ parts: [
+ {
+ functionCall: { name: "read", args: { path: "pixel.png" } },
+ thoughtSignature: "skip_thought_signature_validator",
+ },
+ ],
+ },
{
role: "user",
parts: [
@@ -608,7 +648,13 @@ describe("Gemini route", () => {
model: "gemini-3-flash-preview",
input: [
{ type: "user_input", content: [{ type: "video", uri, mime_type: "video/mp4" }] },
- { type: "function_call", id: "call_video", name: "read", arguments: {} },
+ {
+ type: "function_call",
+ id: "call_video",
+ name: "read",
+ arguments: {},
+ signature: "skip_thought_signature_validator",
+ },
{
type: "function_result",
call_id: "call_video",
@@ -652,7 +698,10 @@ describe("Gemini route", () => {
)
expect(prepared.body.contents).toEqual([
- { role: "model", parts: [{ functionCall: { name: "shot", args: {} } }] },
+ {
+ role: "model",
+ parts: [{ functionCall: { name: "shot", args: {} }, thoughtSignature: "skip_thought_signature_validator" }],
+ },
{
role: "user",
parts: [{ functionResponse: { name: "shot", response: { name: "shot", content: "" } } }],
@@ -702,7 +751,10 @@ describe("Gemini route", () => {
expect(prepared.body.contents).toEqual([
{
role: "model",
- parts: [{ functionCall: { name: "shot", args: {} } }, { functionCall: { name: "shot", args: {} } }],
+ parts: [
+ { functionCall: { name: "shot", args: {} }, thoughtSignature: "skip_thought_signature_validator" },
+ { functionCall: { name: "shot", args: {} }, thoughtSignature: "skip_thought_signature_validator" },
+ ],
},
{
role: "user",
diff --git a/packages/ai/test/provider/google-vertex-interactions.test.ts b/packages/ai/test/provider/google-vertex-interactions.test.ts
index e3b34d021696..172e0ddd1dcc 100644
--- a/packages/ai/test/provider/google-vertex-interactions.test.ts
+++ b/packages/ai/test/provider/google-vertex-interactions.test.ts
@@ -259,6 +259,49 @@ describe("Google Vertex Interactions", () => {
],
})
expect(prepared.body.previous_interaction_id).toBeUndefined()
+
+ const unsigned = yield* compileRequest(
+ ai.request({
+ model,
+ messages: [
+ Message.assistant([
+ ToolCallPart.make({ id: "call_foreign", name: "lookup", input: { city: "Paris" } }),
+ ]),
+ Message.tool({ id: "call_foreign", name: "lookup", result: { weather: "sunny" } }),
+ Message.assistant([
+ ToolCallPart.make({
+ id: "call_signed",
+ name: "lookup",
+ input: { city: "Lyon" },
+ providerMetadata: { vertex: { interactionSignature: "sig_1" } },
+ }),
+ ToolCallPart.make({ id: "call_sibling", name: "lookup", input: { city: "Nice" } }),
+ ]),
+ Message.tool({ id: "call_signed", name: "lookup", result: { weather: "cloudy" } }),
+ Message.tool({ id: "call_sibling", name: "lookup", result: { weather: "warm" } }),
+ ],
+ }),
+ )
+ expect(unsigned.body.input).toEqual([
+ {
+ type: "function_call",
+ id: "call_foreign",
+ name: "lookup",
+ arguments: { city: "Paris" },
+ signature: "skip_thought_signature_validator",
+ },
+ { type: "function_result", call_id: "call_foreign", name: "lookup", result: { weather: "sunny" } },
+ {
+ type: "function_call",
+ id: "call_signed",
+ name: "lookup",
+ arguments: { city: "Lyon" },
+ signature: "sig_1",
+ },
+ { type: "function_call", id: "call_sibling", name: "lookup", arguments: { city: "Nice" } },
+ { type: "function_result", call_id: "call_signed", name: "lookup", result: { weather: "cloudy" } },
+ { type: "function_result", call_id: "call_sibling", name: "lookup", result: { weather: "warm" } },
+ ])
}),
)
})
diff --git a/packages/ai/test/provider/mistral-chat.test.ts b/packages/ai/test/provider/mistral-chat.test.ts
index ded7bf59e5ec..6efdedf04bae 100644
--- a/packages/ai/test/provider/mistral-chat.test.ts
+++ b/packages/ai/test/provider/mistral-chat.test.ts
@@ -123,7 +123,7 @@ describe("Mistral Chat", () => {
},
{
role: "assistant",
- content: "ThinkCalling",
+ content: "Calling",
},
])
const assistant = prepared.body.messages[3]
@@ -177,6 +177,21 @@ describe("Mistral Chat", () => {
{ role: "assistant", content: "Prefix", prefix: true },
])
+ const trailingDropped = yield* compileRequest(
+ ai.request({
+ model,
+ messages: [
+ Message.user("Continue"),
+ Message.assistant("Prefix"),
+ Message.assistant({ type: "reasoning", text: "Dropped unsigned reasoning" }),
+ ],
+ }),
+ )
+ expect(trailingDropped.body.messages).toEqual([
+ { role: "user", content: "Continue" },
+ { role: "assistant", content: "Prefix", prefix: true },
+ ])
+
const uncached = yield* compileRequest(
ai.request({
model,
@@ -227,6 +242,7 @@ describe("Mistral Chat", () => {
messages: [
Message.assistant(" \n "),
Message.assistant({ type: "reasoning", text: "\t" }),
+ Message.assistant({ type: "reasoning", text: "Foreign reasoning without mistral metadata" }),
Message.assistant({ type: "tool-call", id: "Ab12Cd34E", name: "lookup", input: {} }),
],
}),
diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts
index 3d8921c0911b..26750bfec515 100644
--- a/packages/core/src/session/runner/to-llm-message.ts
+++ b/packages/core/src/session/runner/to-llm-message.ts
@@ -171,8 +171,9 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
},
]
// Let the destination adapter handle readable reasoning after a model/provider switch.
- if (item.type === "reasoning")
- return reuseProviderMetadata && !reasoningInterrupted
+ if (item.type === "reasoning") {
+ if (message.error !== undefined || reasoningInterrupted) return []
+ return reuseProviderMetadata
? [
{
type: "reasoning",
@@ -181,8 +182,9 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
},
]
: item.text.length > 0
- ? [{ type: message.error === undefined && !reasoningInterrupted ? "reasoning" : "text", text: item.text }]
+ ? [{ type: "reasoning", text: item.text }]
: []
+ }
// Call-side metadata is model-scoped proof of generation (Gemini thought
// signatures, OpenAI encrypted reasoning): only the producing model may
// replay it.
diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts
index ed9265766cc6..c81143d34e9d 100644
--- a/packages/core/test/session-runner-message.test.ts
+++ b/packages/core/test/session-runner-message.test.ts
@@ -933,7 +933,7 @@ Earlier work
])
})
- test("lowers failed assistant reasoning to text", () => {
+ test("drops failed assistant reasoning while preserving settled hosted tools", () => {
const messages = toLLMMessages(
[
SessionMessage.Assistant.make({
@@ -983,7 +983,6 @@ Earlier work
)
expect(messages[0]?.content).toEqual([
- { type: "text", text: "Partial thought" },
{
type: "tool-call",
id: "hosted-completed",
@@ -1029,7 +1028,7 @@ Earlier work
])
})
- test("drops unfinished reasoning metadata from an interrupted assistant message", () => {
+ test("omits unfinished reasoning from an interrupted assistant message", () => {
const messages = toLLMMessages(
[
SessionMessage.Assistant.make({
@@ -1057,7 +1056,7 @@ Earlier work
model,
)
- expect(messages[0]?.content).toEqual([{ type: "text", text: "Completed summary part" }])
+ expect(messages).toEqual([])
})
test("drops model-scoped continuation metadata after a model switch but keeps hosted result payloads", () => {
diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts
index ce2dc61e1b2e..948d3d861768 100644
--- a/packages/core/test/session-runner.test.ts
+++ b/packages/core/test/session-runner.test.ts
@@ -6153,7 +6153,7 @@ describe("SessionRunnerLLM", () => {
])
})
- scenario("lowers interrupted reasoning before continuing an incomplete stream", function* (s) {
+ scenario("drops interrupted reasoning before continuing an incomplete stream", function* (s) {
yield* s.admit("Continue interrupted reasoning")
yield* s.llm.push(
TestLLM.failAfter(
@@ -6171,19 +6171,10 @@ describe("SessionRunnerLLM", () => {
yield* TestClock.adjust("2400 millis")
yield* Fiber.join(run)
- expect(s.requests[1]?.messages.at(-2)).toMatchObject({
- role: "assistant",
- content: [{ type: "text", text: "Partial thought" }],
- })
- expect(s.requests[1]?.messages.at(-1)).toMatchObject({
- role: "user",
- content: [
- {
- type: "text",
- text: INCOMPLETE_STREAM_CONTINUATION,
- },
- ],
- })
+ expect(s.requests[1]?.messages.slice(-2)).toMatchObject([
+ { role: "user", content: [{ type: "text", text: "Continue interrupted reasoning" }] },
+ { role: "user", content: [{ type: "text", text: INCOMPLETE_STREAM_CONTINUATION }] },
+ ])
expect(yield* s.context).toMatchObject([
{ type: "user" },
Expected.assistant({ finish: "error" }, [Expected.reasoning("Partial thought")]),
From 6700dd87b5d949a3e1748aaf998167ea881cec1b Mon Sep 17 00:00:00 2001
From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Date: Fri, 30 Oct 2026 13:04:16 -0500
Subject: [PATCH 06/19] feat(core): accept a percentage for compaction.buffer
(#60647)
---
.../client/src/promise/generated/types.ts | 4 +++-
packages/core/src/session/compaction.ts | 11 +++++-----
.../core/test/config/normalization.test.ts | 7 +++++++
packages/core/test/session-compaction.test.ts | 9 +++++++++
packages/protocol/openapi.json | 17 +++++++++++++---
packages/schema/src/config/compaction.ts | 11 +++++++++-
services/www/src/docs/content/compaction.mdx | 20 ++++++++++++++++---
services/www/src/docs/content/config.mdx | 4 +++-
services/www/src/docs/content/migrate-v1.mdx | 3 +++
9 files changed, 72 insertions(+), 14 deletions(-)
diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts
index a3330d780995..187df6deb0cf 100644
--- a/packages/client/src/promise/generated/types.ts
+++ b/packages/client/src/promise/generated/types.ts
@@ -366,6 +366,8 @@ export type UsageLimitsMeter = { percent: number; limited: boolean; resetsAt: nu
export type McpProtocol = "legacy" | "auto" | "2026-07-28"
+export type ConfigCompactionPercent = string
+
export type ConfigWorktree = { directory: string }
export type ConfigModelReasoning =
@@ -2424,7 +2426,7 @@ export type ConfigEntry =
}
}
}
- compaction?: { auto?: boolean; keep?: { tokens?: number }; buffer?: number }
+ compaction?: { auto?: boolean; keep?: { tokens?: number }; buffer?: ConfigCompactionPercent | number }
skills?: Array
commands?: {
[x: string]: {
diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts
index e056b0b2bb73..6c6b1ba85b75 100644
--- a/packages/core/src/session/compaction.ts
+++ b/packages/core/src/session/compaction.ts
@@ -42,8 +42,8 @@ import { SessionUsage } from "./usage.js"
export type Settings = {
auto: boolean
- /** Tokens kept free below the model's limits before compacting. Unset keeps 15%, and at least 16k, free. */
- buffer?: number
+ /** Room kept free below the model's limits before compacting: a share such as "20%", or tokens. Unset keeps 15%, and at least 16k, free. */
+ buffer?: number | string
/** Tokens of recent conversation kept verbatim beside the summary. */
keep: number
/** Summarize in the background as the context nears the threshold, so automatic compaction can swap it in. */
@@ -271,7 +271,7 @@ export const layer = Layer.effect(
/** The model that writes the summary, and the request size it aims for. */
const selectModel = Effect.fnUntraced(function* (
context: SessionContext.Loaded,
- buffer: number | undefined,
+ buffer: Settings["buffer"],
native: boolean,
) {
const agent = native ? undefined : yield* agents.get(Agent.ID.make("compaction"))
@@ -1132,12 +1132,13 @@ export const estimatePrompt = (context: SessionContext.Loaded): Estimate => {
* `RESERVE_MIN` when that is more. A summary request that must be shrunk is aimed at the same size. A window too small
* to give up `RESERVE_MIN` keeps `RESERVE_SHARE`.
*/
-const calculateCeiling = (limit: SessionContext.Loaded["model"]["limit"], buffer: number | undefined) => {
+const calculateCeiling = (limit: SessionContext.Loaded["model"]["limit"], buffer: Settings["buffer"]) => {
// With no window at all, only a provider rejection can limit the request.
const window = inputWindow(limit)
if (window <= 0) return Number.POSITIVE_INFINITY
+ const tokens = typeof buffer === "string" ? Math.round((window * Number.parseFloat(buffer)) / 100) : buffer
// A buffer that leaves no room, such as one set for a larger model, is ignored.
- if (buffer !== undefined && buffer < window) return window - buffer
+ if (tokens !== undefined && tokens < window) return window - tokens
return window - Math.max(Math.floor(window * RESERVE_SHARE), window >= 2 * RESERVE_MIN ? RESERVE_MIN : 0)
}
diff --git a/packages/core/test/config/normalization.test.ts b/packages/core/test/config/normalization.test.ts
index 2f03aafe70e4..75c55871c757 100644
--- a/packages/core/test/config/normalization.test.ts
+++ b/packages/core/test/config/normalization.test.ts
@@ -520,6 +520,13 @@ describe("ConfigNormalize", () => {
["conflict", ["compaction", "keep", "tokens"]],
["conflict", ["compaction", "buffer"]],
])
+
+ expect(normalized({ compaction: { buffer: "12.5%" } }).encoded.compaction).toEqual({ buffer: "12.5%" })
+ for (const buffer of ["0%", "100%", "150%"]) {
+ const result = normalized({ compaction: { buffer } })
+ expect(result.encoded.compaction).toBeUndefined()
+ expect(result.diagnostics.map((item) => [item.kind, item.path])).toEqual([["invalid", ["compaction", "buffer"]]])
+ }
})
test("distinguishes empty, mixed, and wholly malformed enabled provider lists", () => {
diff --git a/packages/core/test/session-compaction.test.ts b/packages/core/test/session-compaction.test.ts
index 43d11f472540..1120fd910388 100644
--- a/packages/core/test/session-compaction.test.ts
+++ b/packages/core/test/session-compaction.test.ts
@@ -455,6 +455,15 @@ it.effect("auto compaction estimates current content against the buffered prompt
}),
)
+ // A percentage buffer is a share of the model's window.
+ yield* Effect.scoped(
+ Effect.gen(function* () {
+ yield* compaction.transform((editor) => editor.configure({ buffer: "25%" }))
+ expect(yield* due(input(74_999, contextLimited))).toBe(false)
+ expect(yield* due(input(75_000, contextLimited))).toBe(true)
+ }),
+ )
+
const assistant = input(89_000, contextLimited).messages[0]
const tool = SessionMessage.AssistantTool.make({
type: "tool",
diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json
index d5f7cd24d336..a9862f86ba69 100644
--- a/packages/protocol/openapi.json
+++ b/packages/protocol/openapi.json
@@ -16887,6 +16887,10 @@
"required": ["template"],
"additionalProperties": false
},
+ "Config.Compaction.Percent": {
+ "type": "string",
+ "pattern": "^([1-9]\\d?(\\.\\d+)?)%$"
+ },
"Config.DirectoryEncoded": {
"type": "object",
"properties": {
@@ -17215,9 +17219,16 @@
"additionalProperties": false
},
"buffer": {
- "type": "integer",
- "minimum": 0,
- "maximum": 9007199254740991
+ "anyOf": [
+ {
+ "$ref": "#/components/schemas/Config.Compaction.Percent"
+ },
+ {
+ "type": "integer",
+ "minimum": 0,
+ "maximum": 9007199254740991
+ }
+ ]
}
},
"additionalProperties": false
diff --git a/packages/schema/src/config/compaction.ts b/packages/schema/src/config/compaction.ts
index 7661e57d2a01..e1d4f650a43f 100644
--- a/packages/schema/src/config/compaction.ts
+++ b/packages/schema/src/config/compaction.ts
@@ -7,8 +7,17 @@ export class Keep extends Schema.Class("Config.Compaction.Keep")({
tokens: NonNegativeInt.pipe(optional),
}) {}
+/** A share of the model's window such as "20%" or "12.5%", from 1% up to but not including 100%. */
+export const Percent = Schema.String.check(Schema.isPattern(/^([1-9]\d?(\.\d+)?)%$/u)).annotate({
+ identifier: "Config.Compaction.Percent",
+})
+export type Percent = typeof Percent.Type
+
export class Info extends Schema.Class("Config.Compaction")({
auto: Schema.Boolean.pipe(optional),
keep: Keep.pipe(optional),
- buffer: NonNegativeInt.pipe(optional),
+ buffer: Schema.Union([Percent, NonNegativeInt]).pipe(optional).annotate({
+ description:
+ 'Room kept free below the model\'s context window before automatic compaction, as a share of the window from "1%" to "99%", such as "20%" to compact at 80% full. A token count is also accepted.',
+ }),
}) {}
diff --git a/services/www/src/docs/content/compaction.mdx b/services/www/src/docs/content/compaction.mdx
index a2c1357a1db3..8e2a06c50af3 100644
--- a/services/www/src/docs/content/compaction.mdx
+++ b/services/www/src/docs/content/compaction.mdx
@@ -75,7 +75,7 @@ Add `compaction` to any [OpenCode configuration file](/config):
"compaction": {
"auto": false,
"keep": { "tokens": 24000 },
- "buffer": 16000,
+ "buffer": "20%",
},
}
```
@@ -84,9 +84,23 @@ Add `compaction` to any [OpenCode configuration file](/config):
| --- | ---: | --- |
| `auto` | `true` | Compact automatically near the context limit, and recover once when a provider rejects a request as too long. Manual compaction always works. |
| `keep.tokens` | `15000` | Approximate recent conversation kept beside the summary. Larger values preserve more detail but leave less room for new work. |
-| `buffer` | 15% of the limit, at least 16k | Tokens to keep free below the model's limit. Larger values start automatic compaction earlier. A buffer at least as large as the model's limit is ignored. |
+| `buffer` | `"15%"`, at least 16k | Share of the model's context window kept free. Automatic compaction starts once the conversation reaches the rest. Larger values compact earlier. |
-`keep.tokens` and `buffer` accept non-negative integers.
+`keep.tokens` accepts a non-negative integer.
+
+### Buffer
+
+`buffer` is a percentage of each model's window, so one setting fits every model:
+
+| Model window | `"buffer": "20%"` compacts at |
+| ---: | ---: |
+| 128k | ~102k |
+| 1M | 800k |
+
+- It is the room kept free, not the point where compaction starts: `"20%"` compacts at 80% full.
+- Accepts `"1%"` to `"99%"`, including decimals such as `"12.5%"`.
+- A token count such as `20000` also works. The same count is a large share of a small window and a small share of a large one.
+- A token count at least as large as the model's window is ignored and the default applies.
## Model
diff --git a/services/www/src/docs/content/config.mdx b/services/www/src/docs/content/config.mdx
index 83d5ddc56fe2..b09efba65931 100644
--- a/services/www/src/docs/content/config.mdx
+++ b/services/www/src/docs/content/config.mdx
@@ -363,11 +363,13 @@ Control automatic context compaction and how much recent context it preserves.
"keep": {
"tokens": 15000,
},
- "buffer": 20000,
+ "buffer": "15%",
},
}
```
+`buffer` is the share of the model's context window kept free before automatic compaction starts.
+
Local summaries remain the default. Opt into native provider compaction for both
automatic and manual requests with a provider or model policy:
diff --git a/services/www/src/docs/content/migrate-v1.mdx b/services/www/src/docs/content/migrate-v1.mdx
index e00b89ca671a..82538331f43b 100644
--- a/services/www/src/docs/content/migrate-v1.mdx
+++ b/services/www/src/docs/content/migrate-v1.mdx
@@ -288,6 +288,9 @@ V2 groups the retained-context token budget under `keep` and gives the reserve a
}
```
+The V1 token count carries over as is. V2 also accepts a percentage of the model's window, such as `"buffer": "15%"`,
+which fits models with different window sizes.
+
`auto` keeps its name. V2 has no native `tail_turns` or `prune` field; both legacy fields are ignored with a warning. Recent
context is retained by token budget instead. See [Compaction](/compaction).
From bf5d12c588911a35ec56ad991c3b83cf937f424a Mon Sep 17 00:00:00 2001
From: Dax
Date: Fri, 30 Oct 2026 14:43:09 -0400
Subject: [PATCH 07/19] fix(app,tui): hide MCP integrations from the connect
menu (#60675)
---
packages/app/e2e/regression/settings.spec.ts | 18 ++++-
packages/app/src/providers/connect/dialog.tsx | 47 +++--------
packages/app/src/runtime/i18n/en.ts | 1 -
.../tui/src/component/dialog-integration.tsx | 11 +--
.../cli/cmd/tui/integration-options.test.ts | 12 ---
.../test/cli/tui/dialog-integration.test.tsx | 78 +++++++++++++++++++
6 files changed, 112 insertions(+), 55 deletions(-)
diff --git a/packages/app/e2e/regression/settings.spec.ts b/packages/app/e2e/regression/settings.spec.ts
index de157215d686..25a431eb5309 100644
--- a/packages/app/e2e/regression/settings.spec.ts
+++ b/packages/app/e2e/regression/settings.spec.ts
@@ -885,7 +885,13 @@ test.describe("pages open before slow data", () => {
{ name: "stripe", integrationID: "stripe-oauth", status: { status: "needs_auth", error: "Sign in required" } },
],
integrations: [
- { id: "stripe-oauth", name: "Stripe", methods: [{ id: "oauth", type: "oauth" }], connections: [] },
+ {
+ id: "stripe-oauth",
+ name: "Stripe",
+ methods: [{ id: "oauth", type: "oauth" }],
+ connections: [],
+ metadata: { source: "mcp" },
+ },
],
onIntegrationOAuth: (input) => {
signIns.push(input.integrationID)
@@ -1369,6 +1375,13 @@ test("providers: the connect dialog lists connected providers and manages their
connections: accounts.toSorted((a, b) => Number(b.id === state.active) - Number(a.id === state.active)),
},
{ id: "anthropic", name: "Anthropic", methods: [{ type: "key", label: "API key" }], connections: [] },
+ {
+ id: "linear",
+ name: "Linear",
+ methods: [{ id: "oauth", type: "oauth", label: "Sign in" }],
+ connections: [],
+ metadata: { source: "mcp" },
+ },
],
onCredentialActivate: (credentialID) => {
activated.push(credentialID)
@@ -1389,6 +1402,9 @@ test("providers: the connect dialog lists connected providers and manages their
await expect(
dialog.locator('[data-provider-id="anthropic"]').locator('[data-slot="provider-connected"]'),
).toHaveCount(0)
+ // MCP servers connect from the MCPs dialog and settings, not here.
+ await expect(dialog.locator('[data-provider-id="linear"]')).toHaveCount(0)
+ await expect(dialog.getByText("MCP", { exact: true })).toHaveCount(0)
const container = page.locator('[data-slot="dialog-container"]')
await expect.poll(async () => (await container.boundingBox())?.height).toBe(512)
const picker = await container.boundingBox()
diff --git a/packages/app/src/providers/connect/dialog.tsx b/packages/app/src/providers/connect/dialog.tsx
index 6932a67e2284..a4b252e76d0f 100644
--- a/packages/app/src/providers/connect/dialog.tsx
+++ b/packages/app/src/providers/connect/dialog.tsx
@@ -278,11 +278,6 @@ const POPULAR = ["opencode", "opencode-go", "openai", "github-copilot", "anthrop
function ProviderPicker(props: { directory?: string; onSelect: (provider: string) => void; onPrepare?: () => void }) {
const integrations = useIntegrations(() => props.directory)
const language = useLanguage()
- const data = useData()
- const location = () => (props.directory ? { directory: props.directory } : undefined)
- const mcpServers = () => data.location.mcp.server.list(location()) ?? []
-
- createEffect(() => void data.location.mcp.server.sync(location()).catch(() => undefined))
const [store, setStore] = createStore<{
filter: string
@@ -291,30 +286,23 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
filter: "",
})
+ // MCP servers are connected and managed from the MCP dialog, not here.
const all = createMemo(() => {
const query = store.filter.trim().toLowerCase()
- if (!query) return integrations.list()
-
- return integrations.list().filter((row) => `${row.id} ${row.name}`.toLowerCase().includes(query))
+ return integrations
+ .list()
+ .filter((row) => row.metadata?.source !== "mcp")
+ .filter((row) => !query || `${row.id} ${row.name}`.toLowerCase().includes(query))
})
- // MCP servers keep their own group whether or not they are signed in.
- const providers = createMemo(() => all().filter((row) => row.metadata?.source !== "mcp"))
-
const connected = createMemo(() =>
- providers()
+ all()
.filter((row) => row.connections.length > 0)
.toSorted((a, b) => a.name.localeCompare(b.name)),
)
- const unconnected = createMemo(() => providers().filter((row) => row.connections.length === 0))
-
- const mcp = createMemo(() =>
- all()
- .filter((row) => row.metadata?.source === "mcp")
- .toSorted((a, b) => a.name.localeCompare(b.name)),
- )
+ const unconnected = createMemo(() => all().filter((row) => row.connections.length === 0))
const popular = createMemo(() =>
unconnected()
@@ -330,7 +318,6 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
const groups = () => [
{ title: language.t("dialog.provider.connected"), items: connected },
- { title: language.t("dialog.provider.group.mcp"), items: mcp },
{ title: language.t("dialog.provider.group.popular"), items: popular },
{ title: language.t("dialog.provider.group.services"), items: services },
]
@@ -338,7 +325,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
// An integration that only reads environment variables has nothing to connect from here.
const disabled = (row: IntegrationInfo) => connectMethods(row).length === 0 && providerAccounts(row).length === 0
- const rows = createMemo(() => [...connected(), ...mcp(), ...popular(), ...services()].filter((row) => !disabled(row)))
+ const rows = createMemo(() => [...connected(), ...popular(), ...services()].filter((row) => !disabled(row)))
let picker: HTMLDivElement | undefined
let search: HTMLInputElement | undefined
@@ -400,21 +387,13 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
{(provider) => {
- const mcp = provider.metadata?.source === "mcp"
const connection = () => provider.connections[0]
- const needsSignIn = () =>
- !!connection()?.status ||
- (mcp &&
- mcpServers().some(
- (server) => server.integrationID === provider.id && server.status.status === "needs_auth",
- ))
-
// The server lists the active account first.
const account = () => {
const current = connection()
- if (mcp || !current) return
+ if (!current) return
return current.type === "credential" ? current.label : `$${current.name}`
}
@@ -454,7 +433,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
)}
-
+
!!props.accounts && !state.adding && accounts().length > 0
- const mcp = () =>
- integrations.list().find((integration) => integration.id === props.provider)?.metadata?.source === "mcp"
-
const controller = createProviderConnectionController({
provider: () => integrationID,
directory,
@@ -603,8 +579,7 @@ function ProviderConnection(props: {
})
// Like the TUI, every connection continues to that provider's models, not just the first.
- // MCP servers have no models, so they close instead.
- const first = mcp() ? undefined : connectionGroups()[0]?.models[0]
+ const first = connectionGroups()[0]?.models[0]
if (first) {
setState({ models: true, selectedModel: modelKey(first) })
diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts
index 61bc87a27311..88dbfa0b99a3 100644
--- a/packages/app/src/runtime/i18n/en.ts
+++ b/packages/app/src/runtime/i18n/en.ts
@@ -135,7 +135,6 @@ export const dict = {
"dialog.provider.search.placeholder": "Search providers",
"dialog.provider.empty": "No providers found",
"dialog.provider.group.popular": "Popular",
- "dialog.provider.group.mcp": "MCP",
"dialog.provider.group.services": "Services",
"dialog.provider.environmentOnly": "Environment only",
"dialog.provider.signInRequired": "Sign in required →",
diff --git a/packages/tui/src/component/dialog-integration.tsx b/packages/tui/src/component/dialog-integration.tsx
index 17a5143af19b..b52399ef0c18 100644
--- a/packages/tui/src/component/dialog-integration.tsx
+++ b/packages/tui/src/component/dialog-integration.tsx
@@ -69,7 +69,6 @@ const OAUTH_CODE = /[A-Z0-9]{4}-[A-Z0-9]{4,5}/
export function integrationOptions(list: IntegrationInfo[]) {
return list.toSorted(
(a, b) =>
- Number(b.metadata?.source === "mcp") - Number(a.metadata?.source === "mcp") ||
(INTEGRATION_PRIORITY[a.id] ?? 99) - (INTEGRATION_PRIORITY[b.id] ?? 99) ||
a.name.localeCompare(b.name) ||
a.id.localeCompare(b.id),
@@ -105,9 +104,12 @@ export function DialogIntegration(
const dialog = useDialog()
const theme = useTheme().surface("dialog")
const location = currentLocation.ref ?? data.location.default()
+ // The MCP dialog signs its servers in through `integrationID`; the connect menu leaves them to it.
const integrations = createMemo(() =>
- integrationOptions(data.location.integration.list(location) ?? []).filter(
- (integration) => props.integrationID === undefined || integration.id === props.integrationID,
+ integrationOptions(data.location.integration.list(location) ?? []).filter((integration) =>
+ props.integrationID === undefined
+ ? integration.metadata?.source !== "mcp"
+ : integration.id === props.integrationID,
),
)
@@ -129,7 +131,6 @@ export function DialogIntegration(
const credentials = credentialConnections(integration)
let category = "Services"
if (integration.id in INTEGRATION_PRIORITY) category = "Popular"
- if (integration.metadata?.source === "mcp") category = "MCP"
const status = integration.connections[0]?.status
return {
title: integration.name,
@@ -1262,7 +1263,7 @@ async function connected(
data.location.provider.sync(location),
])
toast.show({ variant: "success", message: `Connected ${integration.name}` })
- if (onConnected && integration.metadata?.source !== "mcp") {
+ if (onConnected) {
onConnected(providerID(data, location, integration.id))
return
}
diff --git a/packages/tui/test/cli/cmd/tui/integration-options.test.ts b/packages/tui/test/cli/cmd/tui/integration-options.test.ts
index 30c35f2f8d92..53a17de2bc32 100644
--- a/packages/tui/test/cli/cmd/tui/integration-options.test.ts
+++ b/packages/tui/test/cli/cmd/tui/integration-options.test.ts
@@ -26,18 +26,6 @@ describe("integrationOptions", () => {
]).map((item) => item.id),
).toEqual(["opencode", "opencode-go", "openai", "anthropic", "mistral", "custom-z"])
})
-
- test("keeps MCP integrations above popular integrations without relying on their IDs", () => {
- expect(
- integrationOptions([
- integration({ id: "openai", name: "OpenAI" }),
- integration({ id: "linear", name: "Linear", metadata: { source: "mcp" } }),
- integration({ id: "github", name: "GitHub", metadata: { source: "mcp" } }),
- integration({ id: "opencode", name: "OpenCode Zen" }),
- integration({ id: "opencode-go", name: "OpenCode Go" }),
- ]).map((item) => item.id),
- ).toEqual(["github", "linear", "opencode", "opencode-go", "openai"])
- })
})
describe("connectMethods", () => {
diff --git a/packages/tui/test/cli/tui/dialog-integration.test.tsx b/packages/tui/test/cli/tui/dialog-integration.test.tsx
index 744ec506f784..55ec1555ff51 100644
--- a/packages/tui/test/cli/tui/dialog-integration.test.tsx
+++ b/packages/tui/test/cli/tui/dialog-integration.test.tsx
@@ -247,6 +247,84 @@ test("uses the active location for integration data without scoping credential r
}
})
+test("leaves MCP integrations out of the connect menu", async () => {
+ await using fixture = await renderMenu()
+ await fixture.app.waitForFrame((frame) => frame.includes("OpenAI"))
+
+ const frame = fixture.app.captureCharFrame()
+ expect(frame).not.toContain("Linear")
+ expect(frame).not.toContain("MCP")
+})
+
+test("still offers an MCP integration when the MCP dialog asks for it", async () => {
+ await using fixture = await renderMenu("linear")
+ await fixture.app.waitForFrame((frame) => frame.includes("Linear"))
+
+ expect(fixture.app.captureCharFrame()).not.toContain("OpenAI")
+})
+
+async function renderMenu(integrationID?: string) {
+ const calls = createFetch(async (url, request) => {
+ if (url.pathname !== "/api/integration") return undefined
+ const directory = decodeURIComponent(request.headers.get("x-opencode-directory") ?? process.cwd())
+ return json({
+ location: { directory, project: { id: "proj_test", directory, canonical: directory } },
+ data: [
+ { id: "openai", name: "OpenAI", methods: [{ type: "key", label: "API key" }], connections: [] },
+ {
+ id: "linear",
+ name: "Linear",
+ methods: [{ type: "oauth", id: "mcp", label: "Sign in" }],
+ connections: [],
+ metadata: { source: "mcp" },
+ },
+ ],
+ })
+ })
+
+ function Probe() {
+ const data = useData()
+ const dialog = useDialog()
+ onMount(() => {
+ void data.location.integration
+ .sync()
+ .then(() => dialog.replace(() => ))
+ })
+ return null
+ }
+
+ const app = await testRender(
+ () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ),
+ { width: 100, height: 30, kittyKeyboard: true },
+ )
+ app.renderer.start()
+
+ return {
+ app,
+ [Symbol.asyncDispose]: async () => app.renderer.destroy(),
+ }
+}
+
async function renderIntegration(activeLocation?: LocationRef, form?: FormFields) {
const events = createEventStream()
const requests: Array<{ method: string; path: string; body?: unknown }> = []
From 2e29481d5c5b92b75b6a29eeca8a8ff8ed166af7 Mon Sep 17 00:00:00 2001
From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Date: Fri, 30 Oct 2026 14:22:20 -0500
Subject: [PATCH 08/19] fix(core): keep MCP quiet while a location shuts down
(#60668)
---
packages/core/src/mcp/index.ts | 7 ++++++-
packages/core/test/mcp.test.ts | 33 +++++++++++++++++++++++++++++++++
2 files changed, 39 insertions(+), 1 deletion(-)
diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts
index 9d1b286049db..285af0a9fc9d 100644
--- a/packages/core/src/mcp/index.ts
+++ b/packages/core/src/mcp/index.ts
@@ -184,6 +184,11 @@ export const layer = (options?: Options) =>
// Server scopes close in parallel on shutdown, so one slow server's stop grace does not delay
// the rest past a service restart's kill deadline and leave them orphaned.
const servers = yield* Scope.fork(root, "parallel")
+ // Connection callbacks run here. Created after `servers`, so the layer scope closes this set first
+ // (finalizers run in reverse): in-flight callbacks are interrupted and later ones never start, so
+ // connections closing with the Location publish nothing. Clients re-fetch on MCP events, which
+ // would boot the Location straight back up.
+ const callbacks = yield* FiberSet.makeRuntime()
const entries = new Map()
const attached = new Map()
@@ -352,7 +357,7 @@ export const layer = (options?: Options) =>
const whenLive =
(name: ServerName, entry: ServerEntry, connection: McpClient.Connection) =>
(effect: Effect.Effect) =>
- fork(
+ callbacks(
Effect.suspend(() => (entry.client === connection ? effect : Effect.void)).pipe(
locks.withLock(name),
Effect.ignore,
diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts
index 89480a7c6e75..3cbeba6cfa30 100644
--- a/packages/core/test/mcp.test.ts
+++ b/packages/core/test/mcp.test.ts
@@ -1883,6 +1883,39 @@ test("isolates nested configured MCP mutations and reconciles them", async () =>
)
})
+test("publishes MCP changes while live but not while the location shuts down", async () => {
+ const published: string[] = []
+ await Effect.runPromise(
+ Effect.scoped(
+ Effect.gen(function* () {
+ const server = yield* resourceServer({ listChanged: true })
+ const scope = yield* Scope.make()
+ const context = yield* Layer.buildWithScope(
+ resourceMcpLayer(server.url, undefined, undefined, { published }),
+ scope,
+ )
+ yield* Effect.gen(function* () {
+ const service = yield* Mcp.Service
+ expect(yield* settled(service)).toEqual({ status: "connected" })
+ published.length = 0
+ yield* Effect.promise(server.sendResourceListChanged)
+ yield* Effect.sync(() => published).pipe(
+ Effect.filterOrFail(
+ (types) => types.includes(McpEvent.ResourcesChanged.type),
+ () => new Error("resource change was not published"),
+ ),
+ Effect.retry({ times: 200, schedule: Schedule.spaced("10 millis") }),
+ )
+ }).pipe(Effect.provide(context))
+ published.length = 0
+ // Location shutdown closes plugin registrations under State.shutdown, so config does not reconcile.
+ yield* State.shutdown(Scope.close(scope, Exit.void))
+ expect(published.filter((type) => type.startsWith("mcp."))).toEqual([])
+ }),
+ ),
+ )
+})
+
testEffect(Layer.empty).live("batches MCP transforms without connecting intermediate configurations", () =>
Effect.gen(function* () {
const server = yield* resourceServer()
From ad711440548abaf6eb7f329f72bbce8357a11c2f Mon Sep 17 00:00:00 2001
From: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Date: Fri, 30 Oct 2026 20:37:36 +0100
Subject: [PATCH 09/19] feat(app): send quotes directly from the comment popup
(#60640)
---
.../e2e/regression/timeline-stream.spec.ts | 150 +++++++++++++++++-
packages/app/src/composer/editor/editor.tsx | 9 ++
.../app/src/composer/editor/interaction.ts | 7 +-
.../app/src/composer/editor/quote-comment.tsx | 56 ++++++-
packages/app/src/composer/model.ts | 9 ++
packages/app/src/composer/state.ts | 14 +-
packages/app/src/composer/submit.test.ts | 120 +++++++++++++-
packages/app/src/composer/submit.ts | 137 +++++++++++-----
packages/app/src/runtime/i18n/en.ts | 4 +
.../line-comment/line-comment.tsx | 115 ++++++++++++--
10 files changed, 553 insertions(+), 68 deletions(-)
diff --git a/packages/app/e2e/regression/timeline-stream.spec.ts b/packages/app/e2e/regression/timeline-stream.spec.ts
index ef0608d0c8a4..2d4ff7b5a652 100644
--- a/packages/app/e2e/regression/timeline-stream.spec.ts
+++ b/packages/app/e2e/regression/timeline-stream.spec.ts
@@ -3,6 +3,7 @@ import type { SessionMessageAssistant, SessionMessageInfo, ShellInfo } from "@op
import { timelinePresets } from "@opencode/session-ui/timeline/detail"
import { createTwoFilesPatch } from "diff"
import { readSessionDraftText } from "../utils/drafts"
+import { holdRoute } from "../utils/app"
import {
assistantID,
assistantMessage,
@@ -2005,6 +2006,7 @@ test("quotes plain timeline text into the composer, with or without a comment",
const user = '[data-slot="user-message-text"]'
const answer = `[data-timeline-part-id="${renderedPartID("prt_quote_answer")}"] [data-slot="text-part-body"]`
await expect(page.locator(answer).getByText("bundle", { exact: true })).toBeVisible()
+ await expect(page.locator('[data-component="composer-editor"]')).toBeFocused()
const select = (from: string, to: string) =>
page.evaluate(
@@ -2050,7 +2052,7 @@ test("quotes plain timeline text into the composer, with or without a comment",
await comment.fill("Why three?")
await expect(popover.getByRole("button")).toHaveCount(1)
await expect(popover.getByRole("button", { name: "Add comment", exact: true })).toBeVisible()
- await popover.getByRole("button", { name: "Add comment", exact: true }).click()
+ await comment.press("Alt+Enter")
await expect(comment).toHaveCount(0)
await expect(chips.nth(1)).toContainText("Why three?")
await chips.nth(1).click()
@@ -2110,6 +2112,151 @@ test("quotes plain timeline text into the composer, with or without a comment",
await expect(chips).toHaveCount(0)
})
+test("quote popup sends independently without consuming the draft or other quotes", async ({ page }) => {
+ const sent: string[] = []
+
+ await setupTimeline(page, {
+ messages: [userMessage(), assistantMessage([textPart("prt_direct_quote", "Keep this quoted passage.")])],
+ onPrompt: (input) => void sent.push(String(input.body.delivery)),
+ })
+
+ const answer = page.locator(
+ `[data-timeline-part-id="${renderedPartID("prt_direct_quote")}"] [data-slot="text-part-body"]`,
+ )
+
+ const editor = page.locator('[data-component="composer-editor"]')
+
+ const quote = page
+ .getByRole("toolbar", { name: "Quote selection" })
+ .getByRole("button", { name: "Quote", exact: true })
+
+ await expect(answer).toHaveText("Keep this quoted passage.")
+ await editor.fill("Keep my separate draft")
+ await selectQuoteText(answer)
+ await quote.click({ modifiers: ["Alt"] })
+ await expect(editor).toBeFocused()
+ await selectQuoteText(answer)
+ await quote.click()
+ const popup = page.locator('[data-component="composer-quote-comment"]')
+
+ const comment = popup.locator("textarea")
+
+ const action = popup.getByRole("button")
+
+ const modifier = await page.evaluate(() => (/(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"))
+
+ await expect(comment).toBeFocused()
+ await comment.fill("Explain this please")
+ await action.focus()
+ await page.keyboard.down(modifier)
+ await expect(action).toHaveAccessibleName("Send quote")
+ await page.keyboard.press("Enter")
+ await page.keyboard.up(modifier)
+ await expect(popup).toHaveCount(0)
+ await expect.poll(() => sent).toEqual(["steer"])
+ await expect(editor).toHaveText("Keep my separate draft")
+ const remaining = page.locator('[data-component="composer-attachments"] [data-component="attachment-card"]')
+
+ await expect(remaining).toHaveCount(1)
+ await expect(remaining).toContainText("Keep this quoted passage.")
+ await expect(remaining).not.toContainText("Explain this please")
+})
+
+test("a pending quote send hides mentions and keeps the comment read-only", async ({ page }) => {
+ const sent: string[] = []
+
+ await setupTimeline(page, {
+ settings: { followUpBehavior: "steer" },
+ sessionStatus: { [sessionID]: { type: "busy" } },
+ messages: [userMessage(), assistantMessage([textPart("prt_quote_mentions", "Keep this quoted passage.")])],
+ findFiles: () => ["src/api.ts"],
+ onPrompt: (input) => void sent.push(String(input.body.delivery)),
+ })
+
+ const answer = page.locator(
+ `[data-timeline-part-id="${renderedPartID("prt_quote_mentions")}"] [data-slot="text-part-body"]`,
+ )
+
+ await expect(answer).toHaveText("Keep this quoted passage.")
+ await expect(page.locator('[data-component="composer-editor"]')).toBeFocused()
+ await selectQuoteText(answer)
+ await page
+ .getByRole("toolbar", { name: "Quote selection" })
+ .getByRole("button", { name: "Quote", exact: true })
+ .click()
+ const popup = page.locator('[data-component="composer-quote-comment"]')
+
+ const comment = popup.locator("textarea")
+
+ const modifier = await page.evaluate(() => (/(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"))
+
+ await comment.fill("Explain @src/a")
+ await expect(popup.locator('[data-slot="line-comment-v2-mention-item"]')).toHaveCount(1)
+ const admission = await holdRoute(page, new RegExp(`/api/session/${sessionID}/prompt(\\?|$)`), { method: "POST" })
+
+ await popup.locator('[data-slot="line-comment-v2-submit"]').click({ modifiers: [modifier] })
+ await admission.arrived
+ await expect(popup.getByRole("button")).toBeDisabled()
+ await expect(comment).toHaveAttribute("readonly", "")
+ await expect(popup.locator('[data-component="loader-v2"]')).toBeVisible()
+ await expect(popup.locator('[data-slot="line-comment-v2-mention-item"]')).toHaveCount(0)
+ await comment.press("Tab")
+ await expect(comment).toHaveValue("Explain @src/a")
+ expect(admission.requests).toHaveLength(1)
+ admission.release()
+ await expect(popup).toHaveCount(0)
+ await expect.poll(() => sent).toEqual(["queue"])
+})
+
+function selectQuoteText(target: Locator) {
+ return target.evaluate((element) => {
+ const range = document.createRange()
+
+ range.selectNodeContents(element)
+ window.getSelection()?.removeAllRanges()
+ window.getSelection()?.addRange(range)
+ })
+}
+
+test("a failed direct quote send keeps the popup and comment for retry", async ({ page }) => {
+ let reject = true
+
+ await setupTimeline(page, {
+ messages: [userMessage(), assistantMessage([textPart("prt_direct_quote_failure", "Keep this quoted passage.")])],
+ onPrompt: () => {
+ if (reject) throw new Error("Quote send failed")
+ },
+ })
+
+ const answer = page.locator(
+ `[data-timeline-part-id="${renderedPartID("prt_direct_quote_failure")}"] [data-slot="text-part-body"]`,
+ )
+
+ await expect(answer).toHaveText("Keep this quoted passage.")
+ await selectQuoteText(answer)
+ await page
+ .getByRole("toolbar", { name: "Quote selection" })
+ .getByRole("button", { name: "Quote", exact: true })
+ .click()
+ const popup = page.locator('[data-component="composer-quote-comment"]')
+
+ const comment = popup.locator("textarea")
+
+ const modifier = await page.evaluate(() => (/(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"))
+
+ await comment.fill("Keep my latest comment")
+ await comment.press(`${modifier}+Enter`)
+ await expect(popup.getByRole("alert")).toHaveText("Couldn’t send. Your comment is still here. Try again.")
+ await expect(comment).toHaveValue("Keep my latest comment")
+ await expect(comment).toBeEditable()
+ reject = false
+ await popup.getByRole("button").click({ modifiers: [modifier] })
+ await expect(popup).toHaveCount(0)
+ await expect(page.locator('[data-component="composer-attachments"] [data-component="attachment-card"]')).toHaveCount(
+ 0,
+ )
+})
+
test("highlights the exact duplicate passage whenever its quote comment is open", async ({ page }) => {
let reject = true
await setupTimeline(page, {
@@ -2230,6 +2377,7 @@ test("highlights the exact duplicate passage whenever its quote comment is open"
await comment.press("Enter")
await expect(comment).toHaveCount(0)
await expect.poll(highlights).toEqual([[]])
+ await expect(editor).toBeFocused()
await select(second, 23)
await toolbar.getByRole("button", { name: "Quote", exact: true }).click({ modifiers: ["Alt"] })
diff --git a/packages/app/src/composer/editor/editor.tsx b/packages/app/src/composer/editor/editor.tsx
index ed030bfb1d89..47061d0ef6bd 100644
--- a/packages/app/src/composer/editor/editor.tsx
+++ b/packages/app/src/composer/editor/editor.tsx
@@ -270,6 +270,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
onQuoteFocus={props.controller.focusQuotePrompt}
onEditQuote={props.controller.editQuote}
onQuoteComment={(quote, comment) => props.controller.commentQuote(quote.key, comment)}
+ quoteSubmit={view.submit.quote}
searchPaths={props.controller.searchPaths}
/>
@@ -686,6 +687,7 @@ function ComposerAttachments(props: {
onQuoteFocus?: () => void
onEditQuote?: (key?: string) => void
onQuoteComment?: (quote: Extract, comment: string) => void
+ quoteSubmit?: ComposerEditorModel["view"]["submit"]["quote"]
searchPaths?: (query: string) => Promise
}) {
const i18n = useI18n()
@@ -882,6 +884,13 @@ function ComposerAttachments(props: {
text={current.text}
comment={current.comment ?? ""}
mention={(query) => props.searchPaths?.(query) ?? Promise.resolve([])}
+ send={
+ props.quoteSubmit && {
+ available: props.quoteSubmit.available(),
+ delivery: props.quoteSubmit.delivery(),
+ onSubmit: (comment) => props.quoteSubmit!.onSubmit(current.key, comment),
+ }
+ }
onSubmit={(comment) => {
props.onQuoteComment?.(current, comment)
props.onEditQuote?.()
diff --git a/packages/app/src/composer/editor/interaction.ts b/packages/app/src/composer/editor/interaction.ts
index 0044a37e4161..315ee2a7762c 100644
--- a/packages/app/src/composer/editor/interaction.ts
+++ b/packages/app/src/composer/editor/interaction.ts
@@ -23,7 +23,7 @@ import {
type ComposerInteractionEvent,
} from "../suggestions/machine"
import { clonePrompt, isAttachment, promptLength } from "../prompt-parts"
-import type { ComposerQueue } from "../adapter"
+import type { ComposerDelivery, ComposerQueue } from "../adapter"
import { isCommentItem } from "../state"
import type { ComposerQuoteAnchor } from "@opencode/plugin/gui"
@@ -49,6 +49,11 @@ export type ComposerEditorView = {
held?: Accessor
working?: Accessor
queue?: ComposerQueue
+ quote?: {
+ available: Accessor
+ delivery: Accessor
+ onSubmit: (key: string, comment: string) => Promise
+ }
onSubmit: (options?: { alternate?: boolean }) => void
onStop: () => void
}
diff --git a/packages/app/src/composer/editor/quote-comment.tsx b/packages/app/src/composer/editor/quote-comment.tsx
index f59349d48461..3ebaf952cce8 100644
--- a/packages/app/src/composer/editor/quote-comment.tsx
+++ b/packages/app/src/composer/editor/quote-comment.tsx
@@ -1,4 +1,4 @@
-import { createMemo, onCleanup } from "solid-js"
+import { createMemo, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -6,6 +6,15 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
import { LineCommentEditor } from "@opencode/ui/line-comment"
import { useLanguage } from "@/runtime/i18n/language"
import type { ComposerQuoteAnchor } from "@opencode/plugin/gui"
+import type { ComposerDelivery } from "../adapter"
+
+const IS_MAC = /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
+
+const sendLabels = {
+ queue: "session.quote.queue",
+ steer: "session.quote.steer",
+ send: "session.quote.send",
+} as const
const GAP = 8
@@ -19,6 +28,11 @@ export function QuoteCommentEditor(props: {
mention: (query: string) => Promise
onSubmit: (comment: string) => void
onClose: (focus?: boolean) => void
+ send?: {
+ available: boolean
+ delivery: ComposerDelivery | undefined
+ onSubmit: (comment: string) => Promise
+ }
}) {
const language = useLanguage()
@@ -30,6 +44,8 @@ export function QuoteCommentEditor(props: {
revision: 0,
height: 0,
openingHeight: 0,
+ sending: false,
+ failed: false,
})
const width = () => Math.min(WIDTH, state.viewport - GAP * 2)
@@ -47,6 +63,8 @@ export function QuoteCommentEditor(props: {
let frame: number | undefined
+ let mounted = true
+
const schedule = () => {
if (frame !== undefined) return
frame = requestAnimationFrame(() => {
@@ -61,6 +79,8 @@ export function QuoteCommentEditor(props: {
}
onCleanup(() => {
+ mounted = false
+
if (frame !== undefined) cancelAnimationFrame(frame)
})
@@ -72,7 +92,7 @@ export function QuoteCommentEditor(props: {
if (target instanceof Node && (element?.contains(target) || props.anchor.contains(target))) return
- if (state.draft.trim() === props.comment.trim()) props.onClose()
+ if (!state.sending && state.draft.trim() === props.comment.trim()) props.onClose()
})
const left = () => Math.min(Math.max(source()?.left ?? state.rect.left, GAP), state.viewport - width() - GAP)
@@ -88,6 +108,28 @@ export function QuoteCommentEditor(props: {
const availableHeight = () => Math.max(0, bottom() - GAP)
const top = () => Math.max(GAP, bottom() - state.height)
+ const alternateSubmit = createMemo(() => {
+ const send = props.send
+
+ if (!send) return
+
+ return {
+ label: language.t(sendLabels[send.delivery ?? "send"]),
+ keys: [IS_MAC ? "⌘" : language.t("common.key.ctrl"), "⏎"],
+ disabled: !send.available,
+ onSubmit: async (comment: string) => {
+ setState({ sending: true, failed: false })
+
+ const sent = await send.onSubmit(comment)
+
+ if (!mounted) return
+ setState({ sending: false, failed: !sent })
+
+ if (sent) props.onClose()
+ },
+ }
+ })
+
return (
setState("draft", value)}
onCancel={() => props.onClose(true)}
onSubmit={props.onSubmit}
+ submitting={state.sending}
+ alternateSubmit={alternateSubmit()}
placeholder={language.t("session.quote.optionalCommentPlaceholder")}
submitLabel={language.t(
props.comment.trim()
@@ -137,6 +181,14 @@ export function QuoteCommentEditor(props: {
mention={{ items: props.mention }}
selection={
{props.text}}
/>
+
+
+ {language.t("session.quote.sendFailed")}
+
+
diff --git a/packages/app/src/composer/model.ts b/packages/app/src/composer/model.ts
index 2350812be0b7..dfcb828760fa 100644
--- a/packages/app/src/composer/model.ts
+++ b/packages/app/src/composer/model.ts
@@ -536,6 +536,15 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
held: submission.held,
working: adapter.working,
queue: options?.queue,
+ quote:
+ adapter.kind === "active-session"
+ ? {
+ available: () =>
+ available() && !adapter.controls().model.loading && !submission.held() && !options?.queue?.undoing(),
+ delivery: () => (adapter.working() ? (options?.queue?.alternate() ?? "steer") : undefined),
+ onSubmit: submission.submitQuote,
+ }
+ : undefined,
onSubmit: (submitOptions) => {
if (!available()) return
const queue = options?.queue
diff --git a/packages/app/src/composer/state.ts b/packages/app/src/composer/state.ts
index 13fdf1669da8..e3a38b70d4a4 100644
--- a/packages/app/src/composer/state.ts
+++ b/packages/app/src/composer/state.ts
@@ -185,17 +185,19 @@ function createComposerStateValue(store: ComposerStore, setStore: SetStoreFuncti
},
/** Edits the file comment, note, or quote with this commentID; only file comments carry a preview. */
updateComment(commentID: string, next: { readonly comment?: string; readonly preview?: string }) {
- setStore("context", "items", (items) =>
- items.map((item) => {
- if (item.commentID !== commentID) return item
-
- if (item.type === "quote") return { ...item, comment: next.comment }
+ setStore(
+ "context",
+ "items",
+ (item) => item.commentID === commentID,
+ (item) => {
+ // Preserve quote identity so an open comment editor does not remount.
+ if (item.type === "quote") return { comment: next.comment }
const value =
item.type === "file" ? { ...item, ...next } : { ...item, comment: next.comment ?? item.comment }
return { ...value, key: contextItemKey(value) }
- }),
+ },
)
clearRetry()
},
diff --git a/packages/app/src/composer/submit.test.ts b/packages/app/src/composer/submit.test.ts
index 0d0caa00ae36..79c84013fd78 100644
--- a/packages/app/src/composer/submit.test.ts
+++ b/packages/app/src/composer/submit.test.ts
@@ -84,14 +84,14 @@ function submitInput(
commands: () => readonly { name: string }[] | undefined = () => [],
history: string[] = [],
clientCommand?: (text: string) => (() => void | Promise