From 593903fb5ce8843d1a84a64787f8103b92a31fee Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 13:56:03 +0000 Subject: [PATCH 01/12] feat(cli): treat opus 4.8 as adaptive thinking model like 4.7 --- .changeset/opus-4-8-adaptive-thinking.md | 5 +++ .../src/plugin/github-copilot/models.ts | 4 +- packages/opencode/src/provider/transform.ts | 12 +++-- .../test/kilocode/transform-opus-4.7.test.ts | 44 +++++++++++++++++++ 4 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 .changeset/opus-4-8-adaptive-thinking.md diff --git a/.changeset/opus-4-8-adaptive-thinking.md b/.changeset/opus-4-8-adaptive-thinking.md new file mode 100644 index 0000000000..38f6a54776 --- /dev/null +++ b/.changeset/opus-4-8-adaptive-thinking.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Treat Claude Opus 4.8 as an adaptive thinking model, matching Opus 4.7. diff --git a/packages/opencode/src/plugin/github-copilot/models.ts b/packages/opencode/src/plugin/github-copilot/models.ts index acccf146d0..e2524288b7 100644 --- a/packages/opencode/src/plugin/github-copilot/models.ts +++ b/packages/opencode/src/plugin/github-copilot/models.ts @@ -125,7 +125,9 @@ function build(key: string, remote: Item, url: string, prev?: Model): Model { variants[effort] = { thinking: { type: "adaptive", - ...(model.api.id.includes("opus-4.7") ? { display: "summarized" } : {}), + ...(model.api.id.includes("opus-4.7") || model.api.id.includes("opus-4.8") + ? { display: "summarized" } + : {}), // kilocode_change - treat opus-4.8 like opus-4.7 }, effort, } diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index ab7a81ec81..c90ad79408 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -542,7 +542,8 @@ function openaiReasoningEfforts(apiId: string, releaseDate: string): string[] | } function anthropicAdaptiveEfforts(apiId: string): string[] | null { - if (["opus-4-7", "opus-4.7"].some((v) => apiId.includes(v))) { + if (["opus-4-7", "opus-4.7", "opus-4-8", "opus-4.8"].some((v) => apiId.includes(v))) { + // kilocode_change - treat opus-4.8 like opus-4.7 return ["low", "medium", "high", "xhigh", "max"] } if (["opus-4-6", "opus-4.6", "sonnet-4-6", "sonnet-4.6"].some((v) => apiId.includes(v))) { @@ -780,7 +781,8 @@ export function variants(model: Provider.Model): Record model.api.id.includes(v)) ? { display: "summarized" } : {}), }, @@ -827,7 +830,8 @@ export function variants(model: Provider.Model): Record model.api.id.includes(v)) ? { display: "summarized" } : {}), }, diff --git a/packages/opencode/test/kilocode/transform-opus-4.7.test.ts b/packages/opencode/test/kilocode/transform-opus-4.7.test.ts index 66c9b1d850..59584a44a1 100644 --- a/packages/opencode/test/kilocode/transform-opus-4.7.test.ts +++ b/packages/opencode/test/kilocode/transform-opus-4.7.test.ts @@ -75,6 +75,50 @@ describe("ProviderTransform.variants - Claude Opus 4.7", () => { }) }) + test("opus-4-8 returns adaptive thinking variants including xhigh (native anthropic)", () => { + const model = mockModel({ + api: { + id: "claude-opus-4-8", + url: "https://api.anthropic.com", + npm: "@ai-sdk/anthropic", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.xhigh).toEqual({ + thinking: { type: "adaptive", display: "summarized" }, + effort: "xhigh", + }) + }) + + test("opus-4.8 dot-form returns adaptive thinking variants via @ai-sdk/gateway", () => { + const model = mockModel({ + id: "anthropic/claude-opus-4-8", + api: { + id: "anthropic/claude-opus-4.8", + url: "https://gateway.ai", + npm: "@ai-sdk/gateway", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + }) + + test("opus-4-8 on bedrock returns adaptive reasoningConfig with xhigh", () => { + const model = mockModel({ + api: { + id: "anthropic.claude-opus-4-8", + url: "https://bedrock.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + }) + const result = ProviderTransform.variants(model) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.xhigh).toEqual({ + reasoningConfig: { type: "adaptive", maxReasoningEffort: "xhigh", display: "summarized" }, + }) + }) + test("opus-4-6 keeps original adaptive efforts without xhigh", () => { const model = mockModel({ api: { From a55c854f4ee249f809e81fa7cab67ba1c7886772 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 14:10:54 +0000 Subject: [PATCH 02/12] fix(cli): annotate opus 4.8 changes and use array-form id checks --- .../opencode/src/plugin/github-copilot/models.ts | 6 ++++-- packages/opencode/src/provider/transform.ts | 14 +++++++++----- .../test/kilocode/transform-opus-4.7.test.ts | 2 +- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/plugin/github-copilot/models.ts b/packages/opencode/src/plugin/github-copilot/models.ts index e2524288b7..7f666b0cfb 100644 --- a/packages/opencode/src/plugin/github-copilot/models.ts +++ b/packages/opencode/src/plugin/github-copilot/models.ts @@ -125,9 +125,11 @@ function build(key: string, remote: Item, url: string, prev?: Model): Model { variants[effort] = { thinking: { type: "adaptive", - ...(model.api.id.includes("opus-4.7") || model.api.id.includes("opus-4.8") + // kilocode_change start - treat opus-4.8 like opus-4.7 + ...(["opus-4-7", "opus-4.7", "opus-4-8", "opus-4.8"].some((v) => model.api.id.includes(v)) ? { display: "summarized" } - : {}), // kilocode_change - treat opus-4.8 like opus-4.7 + : {}), + // kilocode_change end }, effort, } diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index c90ad79408..640aadbb15 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -542,10 +542,11 @@ function openaiReasoningEfforts(apiId: string, releaseDate: string): string[] | } function anthropicAdaptiveEfforts(apiId: string): string[] | null { + // kilocode_change start - treat opus-4.8 like opus-4.7 if (["opus-4-7", "opus-4.7", "opus-4-8", "opus-4.8"].some((v) => apiId.includes(v))) { - // kilocode_change - treat opus-4.8 like opus-4.7 return ["low", "medium", "high", "xhigh", "max"] } + // kilocode_change end if (["opus-4-6", "opus-4.6", "sonnet-4-6", "sonnet-4.6"].some((v) => apiId.includes(v))) { return ["low", "medium", "high", "max"] } @@ -781,10 +782,11 @@ export function variants(model: Provider.Model): Record model.api.id.includes(v))) { efforts = ["medium"] } + // kilocode_change end // Efforts currently supported are: low, medium, high efforts = efforts.filter((v) => v !== "max" && v !== "xhigh") } @@ -794,10 +796,11 @@ export function variants(model: Provider.Model): Record model.api.id.includes(v)) ? { display: "summarized" } : {}), + // kilocode_change end }, effort, }, @@ -830,10 +833,11 @@ export function variants(model: Provider.Model): Record model.api.id.includes(v)) ? { display: "summarized" } : {}), + // kilocode_change end }, }, ]), diff --git a/packages/opencode/test/kilocode/transform-opus-4.7.test.ts b/packages/opencode/test/kilocode/transform-opus-4.7.test.ts index 59584a44a1..af26d4163f 100644 --- a/packages/opencode/test/kilocode/transform-opus-4.7.test.ts +++ b/packages/opencode/test/kilocode/transform-opus-4.7.test.ts @@ -30,7 +30,7 @@ function mockModel(overrides: Partial = {}): any { } } -describe("ProviderTransform.variants - Claude Opus 4.7", () => { +describe("ProviderTransform.variants - Claude Opus 4.7 / 4.8", () => { test("opus-4-7 returns adaptive thinking variants including xhigh (native anthropic)", () => { const model = mockModel({ api: { From 56b40c9a6c9f8776f3729f2dfb882ab59cc09713 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 14:29:10 +0000 Subject: [PATCH 03/12] chore: add temporary PR description file --- .tmp_pr_10735_description.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .tmp_pr_10735_description.md diff --git a/.tmp_pr_10735_description.md b/.tmp_pr_10735_description.md new file mode 100644 index 0000000000..8d98edb379 --- /dev/null +++ b/.tmp_pr_10735_description.md @@ -0,0 +1,34 @@ +# PR #10735 — prepared description (pending gh auth) + +## Summary + +Treats Claude Opus 4.8 as an adaptive thinking model, mirroring the existing Opus 4.7 handling in `ProviderTransform.variants`. + +Fixes #10732. + +## The bug + +On Bedrock, `global.anthropic.claude-opus-4-8` / `us.anthropic.claude-opus-4-8` only exposed **High** and **Max**, and any request failed with: + +> "thinking.type.enabled" is not supported for this model. Use "thinking.type.adaptive" and "output_config.effort" to control thinking behavior. + +`anthropicAdaptiveEfforts(model.api.id)` only matched `opus-4-6` / `opus-4-7`, so it returned `null` for 4.8. With no adaptive efforts, the `@ai-sdk/amazon-bedrock` branch fell through to the non-adaptive Anthropic path, which emits `reasoningConfig: { type: "enabled", budgetTokens }` for `high`/`max` only — matching both the missing efforts and the error. + +## The fix + +Add `opus-4-8` / `opus-4.8` to `anthropicAdaptiveEfforts`, which flips the `if (adaptiveEfforts)` gate so the Bedrock branch instead emits `reasoningConfig: { type: "adaptive", maxReasoningEffort: effort, display: "summarized" }` across the full `low/medium/high/xhigh/max` set — exactly as 4.7 already does. The same treatment is applied on the native Anthropic, Vertex, and Copilot paths. All ID checks use the array form so both hyphen (`opus-4-8`) and dot (`opus-4.8`) ids match. + +## Verification against `@ai-sdk/amazon-bedrock@4.0.96` (our pinned version) + +Inspected the published package to confirm the variant output is both valid and produces exactly the fields the API demands: + +- **Options schema** accepts `reasoningConfig.type: "enabled" | "disabled" | "adaptive"`, `maxReasoningEffort: low|medium|high|xhigh|max`, and `display: "omitted" | "summarized"` — a precise match for what `variants()` now emits. +- **Request mapping** (`amazon-bedrock-chat-language-model`): for an Anthropic model, `reasoningConfig.type: "adaptive"` is serialized to `additionalModelRequestFields.thinking = { type: "adaptive", display }`, and `maxReasoningEffort` to `additionalModelRequestFields.output_config = { effort }`. `isThinkingEnabled` is true for both `"enabled"` and `"adaptive"`; `isAnthropicModel` is `modelId.includes("anthropic")`, so the region-prefixed `global.`/`us.` ids qualify. +- This yields `thinking.type.adaptive` + `output_config.effort` — exactly what the error message instructs, confirming the 400 is resolved. +- **Before** the fix the same mapping turned `type: "enabled"` into `thinking.type.enabled`, reproducing the reported error. + +## Package update needed? + +**No.** `@ai-sdk/amazon-bedrock@4.0.96` already supports `adaptive`, `maxReasoningEffort`, and `display`. The bug was purely the missing model-id gate in our transform, not a missing SDK capability. + +All edits to shared upstream files are wrapped in `kilocode_change start/end` markers. Added 4.8 cases to the Kilo-owned `transform-opus-4.7.test.ts`. From 8b530ac76848ca0c4fc317c35524e4c595567ded Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Fri, 29 May 2026 14:37:56 +0000 Subject: [PATCH 04/12] docs(cli): clarify opus 4.8 changeset as bedrock reasoning fix --- .changeset/opus-4-8-adaptive-thinking.md | 2 +- .tmp_pr_10735_description.md | 34 ------------------------ 2 files changed, 1 insertion(+), 35 deletions(-) delete mode 100644 .tmp_pr_10735_description.md diff --git a/.changeset/opus-4-8-adaptive-thinking.md b/.changeset/opus-4-8-adaptive-thinking.md index 38f6a54776..30b7026287 100644 --- a/.changeset/opus-4-8-adaptive-thinking.md +++ b/.changeset/opus-4-8-adaptive-thinking.md @@ -2,4 +2,4 @@ "@kilocode/cli": patch --- -Treat Claude Opus 4.8 as an adaptive thinking model, matching Opus 4.7. +Fix Claude Opus 4.8 reasoning on Amazon Bedrock by treating it as an adaptive thinking model like Opus 4.7. This resolves the "thinking.type.enabled is not supported for this model" error and exposes the full low/medium/high/xhigh/max reasoning effort range. diff --git a/.tmp_pr_10735_description.md b/.tmp_pr_10735_description.md deleted file mode 100644 index 8d98edb379..0000000000 --- a/.tmp_pr_10735_description.md +++ /dev/null @@ -1,34 +0,0 @@ -# PR #10735 — prepared description (pending gh auth) - -## Summary - -Treats Claude Opus 4.8 as an adaptive thinking model, mirroring the existing Opus 4.7 handling in `ProviderTransform.variants`. - -Fixes #10732. - -## The bug - -On Bedrock, `global.anthropic.claude-opus-4-8` / `us.anthropic.claude-opus-4-8` only exposed **High** and **Max**, and any request failed with: - -> "thinking.type.enabled" is not supported for this model. Use "thinking.type.adaptive" and "output_config.effort" to control thinking behavior. - -`anthropicAdaptiveEfforts(model.api.id)` only matched `opus-4-6` / `opus-4-7`, so it returned `null` for 4.8. With no adaptive efforts, the `@ai-sdk/amazon-bedrock` branch fell through to the non-adaptive Anthropic path, which emits `reasoningConfig: { type: "enabled", budgetTokens }` for `high`/`max` only — matching both the missing efforts and the error. - -## The fix - -Add `opus-4-8` / `opus-4.8` to `anthropicAdaptiveEfforts`, which flips the `if (adaptiveEfforts)` gate so the Bedrock branch instead emits `reasoningConfig: { type: "adaptive", maxReasoningEffort: effort, display: "summarized" }` across the full `low/medium/high/xhigh/max` set — exactly as 4.7 already does. The same treatment is applied on the native Anthropic, Vertex, and Copilot paths. All ID checks use the array form so both hyphen (`opus-4-8`) and dot (`opus-4.8`) ids match. - -## Verification against `@ai-sdk/amazon-bedrock@4.0.96` (our pinned version) - -Inspected the published package to confirm the variant output is both valid and produces exactly the fields the API demands: - -- **Options schema** accepts `reasoningConfig.type: "enabled" | "disabled" | "adaptive"`, `maxReasoningEffort: low|medium|high|xhigh|max`, and `display: "omitted" | "summarized"` — a precise match for what `variants()` now emits. -- **Request mapping** (`amazon-bedrock-chat-language-model`): for an Anthropic model, `reasoningConfig.type: "adaptive"` is serialized to `additionalModelRequestFields.thinking = { type: "adaptive", display }`, and `maxReasoningEffort` to `additionalModelRequestFields.output_config = { effort }`. `isThinkingEnabled` is true for both `"enabled"` and `"adaptive"`; `isAnthropicModel` is `modelId.includes("anthropic")`, so the region-prefixed `global.`/`us.` ids qualify. -- This yields `thinking.type.adaptive` + `output_config.effort` — exactly what the error message instructs, confirming the 400 is resolved. -- **Before** the fix the same mapping turned `type: "enabled"` into `thinking.type.enabled`, reproducing the reported error. - -## Package update needed? - -**No.** `@ai-sdk/amazon-bedrock@4.0.96` already supports `adaptive`, `maxReasoningEffort`, and `display`. The bug was purely the missing model-id gate in our transform, not a missing SDK capability. - -All edits to shared upstream files are wrapped in `kilocode_change start/end` markers. Added 4.8 cases to the Kilo-owned `transform-opus-4.7.test.ts`. From 188d22afcfdb58d8c19fc4f97d0ce825398e5400 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Sun, 31 May 2026 21:20:26 +0000 Subject: [PATCH 05/12] fix(cli): match old opus-4.7 id-form checks for 4.8 (dot-only where 4.7 was) --- packages/opencode/src/plugin/github-copilot/models.ts | 2 +- packages/opencode/src/provider/transform.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/plugin/github-copilot/models.ts b/packages/opencode/src/plugin/github-copilot/models.ts index 7f666b0cfb..286e7dc59e 100644 --- a/packages/opencode/src/plugin/github-copilot/models.ts +++ b/packages/opencode/src/plugin/github-copilot/models.ts @@ -126,7 +126,7 @@ function build(key: string, remote: Item, url: string, prev?: Model): Model { thinking: { type: "adaptive", // kilocode_change start - treat opus-4.8 like opus-4.7 - ...(["opus-4-7", "opus-4.7", "opus-4-8", "opus-4.8"].some((v) => model.api.id.includes(v)) + ...(model.api.id.includes("opus-4.7") || model.api.id.includes("opus-4.8") ? { display: "summarized" } : {}), // kilocode_change end diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 640aadbb15..e6f157c18e 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -783,7 +783,7 @@ export function variants(model: Provider.Model): Record model.api.id.includes(v))) { + if (model.api.id.includes("opus-4.7") || model.api.id.includes("opus-4.8")) { efforts = ["medium"] } // kilocode_change end From 6e62d40103d1c3afd06705787b166b647b523dbc Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Mon, 1 Jun 2026 07:27:17 +0000 Subject: [PATCH 06/12] chore: sync @ai-sdk/openai version across packages --- bun.lock | 8 +------- packages/kilo-gateway/package.json | 2 +- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/bun.lock b/bun.lock index 0b94452809..858138b896 100644 --- a/bun.lock +++ b/bun.lock @@ -102,7 +102,7 @@ "dependencies": { "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/anthropic": "3.0.71", - "@ai-sdk/openai": "3.0.48", + "@ai-sdk/openai": "3.0.53", "@ai-sdk/openai-compatible": "2.0.37", "@clack/prompts": "1.0.0-alpha.1", "@kilocode/plugin": "workspace:*", @@ -4519,8 +4519,6 @@ "@kilocode/kilo-docs/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "@kilocode/kilo-gateway/@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="], - "@kilocode/kilo-gateway/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="], "@kilocode/kilo-indexing/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], @@ -5091,8 +5089,6 @@ "@hey-api/openapi-ts/open/wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], - "@kilocode/kilo-gateway/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - "@kilocode/kilo-gateway/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], "@manypkg/find-root/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], @@ -5465,8 +5461,6 @@ "@kilocode/kilo-gateway/@ai-sdk/openai-compatible/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@kilocode/kilo-gateway/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@manypkg/find-root/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "@morphllm/morphsdk/ai/@ai-sdk/gateway/@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index c55f7888d5..2a5afa4bd1 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -36,7 +36,7 @@ "@kilocode/sdk": "workspace:*", "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/anthropic": "3.0.71", - "@ai-sdk/openai": "3.0.48", + "@ai-sdk/openai": "3.0.53", "@ai-sdk/openai-compatible": "2.0.37", "@openrouter/ai-sdk-provider": "2.8.1", "@clack/prompts": "1.0.0-alpha.1", From 1c06a1dbc7c2339fc7b7dd4bb45e31c9d80f259d Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 1 Jun 2026 10:21:48 +0200 Subject: [PATCH 07/12] fix(vscode): preserve diff scroll during agent edits --- .changeset/steady-review-scroll.md | 5 + .../tests/diff-scroll-preservation.spec.ts | 102 ++++++++++++++++++ .../tests/visual-regression.spec.mts | 1 + .../tests/visual-regression.spec.ts | 1 + .../webview-ui/agent-manager/DiffPanel.tsx | 5 +- .../agent-manager/FullScreenDiffView.tsx | 5 +- .../webview-ui/agent-manager/diff-state.ts | 40 +++++++ .../src/stories/agent-manager.stories.tsx | 89 ++++++++++++++- 8 files changed, 243 insertions(+), 5 deletions(-) create mode 100644 .changeset/steady-review-scroll.md create mode 100644 packages/kilo-vscode/tests/diff-scroll-preservation.spec.ts diff --git a/.changeset/steady-review-scroll.md b/.changeset/steady-review-scroll.md new file mode 100644 index 0000000000..aba17d7672 --- /dev/null +++ b/.changeset/steady-review-scroll.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Preserve the Changes review scroll position while agents update files. diff --git a/packages/kilo-vscode/tests/diff-scroll-preservation.spec.ts b/packages/kilo-vscode/tests/diff-scroll-preservation.spec.ts new file mode 100644 index 0000000000..82246023dc --- /dev/null +++ b/packages/kilo-vscode/tests/diff-scroll-preservation.spec.ts @@ -0,0 +1,102 @@ +import { expect, test, type Page } from "@playwright/test" + +const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern" +const STORY_ID = "agentmanager--full-screen-diff-agent-edit-scroll" + +function storyUrl() { + return `/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}` +} + +async function disableAnimations(page: Page) { + await page.addStyleTag({ + content: ` + *, *::before, *::after { + animation-duration: 0s !important; + animation-delay: 0s !important; + transition-duration: 0s !important; + transition-delay: 0s !important; + } + `, + }) +} + +async function openStory(page: Page) { + await page.setViewportSize({ width: 800, height: 720 }) + await page.addInitScript(() => { + const win = window as Window & { nativeIntersectionObserver?: typeof IntersectionObserver } + win.nativeIntersectionObserver = window.IntersectionObserver + Object.defineProperty(window, "IntersectionObserver", { configurable: true, value: undefined, writable: true }) + }) + await page.goto(storyUrl(), { waitUntil: "load" }) + await disableAnimations(page) + await page.waitForSelector("#storybook-root *", { state: "attached" }) + + const first = page.locator('[data-file-path="src/agent-edit.ts"] [data-component="diff"]') + await expect.poll(async () => first.evaluate((el) => el.getBoundingClientRect().height)).toBeGreaterThan(3_000) + return first +} + +test("preserves diff scroll position while an agent edit refreshes a file", async ({ page }) => { + const first = await openStory(page) + const scroller = page.locator(".am-review-diff") + const target = page.locator('[data-file-path="src/target.ts"]') + + // The initial tall diff rendered eagerly. Restore the real observer before + // moving it offscreen so an unfixed row remount takes the deferred path. + await page.evaluate(() => { + const win = window as Window & { nativeIntersectionObserver?: typeof IntersectionObserver } + Object.defineProperty(window, "IntersectionObserver", { + configurable: true, + value: win.nativeIntersectionObserver, + writable: true, + }) + }) + + await scroller.evaluate((el) => { + const target = el.querySelector('[data-file-path="src/target.ts"]') + if (!(target instanceof HTMLElement)) throw new Error("Target diff row not found") + el.scrollTop += target.getBoundingClientRect().top - el.getBoundingClientRect().top - 24 + }) + + const before = await scroller.evaluate((el) => el.scrollTop) + const top = await target.evaluate((el) => el.getBoundingClientRect().top) + expect(before).toBeGreaterThan(3_000) + + await page.getByRole("button", { name: "Apply agent edit" }).click() + await expect(page.getByTestId("agent-edit-version")).toHaveText("after") + await expect.poll(async () => first.evaluate((el) => el.getBoundingClientRect().height)).toBeGreaterThan(3_000) + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))) + + const after = await scroller.evaluate((el) => el.scrollTop) + const next = await target.evaluate((el) => el.getBoundingClientRect().top) + expect(after).toBeCloseTo(before, 0) + expect(next).toBeCloseTo(top, 0) +}) + +test("remounts diff rows when the review context changes", async ({ page }) => { + const first = await openStory(page) + await page.evaluate(() => { + class IdleObserver { + readonly root = null + readonly rootMargin = "0px" + readonly thresholds = [] + + disconnect() {} + observe() {} + takeRecords() { + return [] + } + unobserve() {} + } + + Object.defineProperty(window, "IntersectionObserver", { + configurable: true, + value: IdleObserver, + writable: true, + }) + }) + + await page.getByRole("button", { name: "Switch review context" }).click() + await expect(page.getByTestId("review-context")).toHaveText("changed-context") + await expect.poll(async () => first.evaluate((el) => el.getBoundingClientRect().height)).toBe(1_200) +}) diff --git a/packages/kilo-vscode/tests/visual-regression.spec.mts b/packages/kilo-vscode/tests/visual-regression.spec.mts index 6877dfe7b6..0ac3d83197 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.mts +++ b/packages/kilo-vscode/tests/visual-regression.spec.mts @@ -50,6 +50,7 @@ async function disableAnimations(page: Page) { // Permission dock config-preloaded has non-deterministic toggle rendering. const SKIP = new Set([ "agentmanager--worktree-item-busy", + "agentmanager--full-screen-diff-agent-edit-scroll", "composite-webview--permission-dock-config-preloaded", ]) diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts b/packages/kilo-vscode/tests/visual-regression.spec.ts index 75940ae1de..b1b7fe412c 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts @@ -50,6 +50,7 @@ async function disableAnimations(page: Page) { // Permission dock config-preloaded has non-deterministic toggle rendering. const SKIP = new Set([ "agentmanager--worktree-item-busy", + "agentmanager--full-screen-diff-agent-edit-scroll", "agentmanager--pr-badge-checks-pending", "composite-webview--permission-dock-config-preloaded", ]) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx index acef4a9c2f..ffba465c16 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx @@ -41,7 +41,7 @@ import { import { DiffEndMarker } from "./DiffEndMarker" import { treeOrder } from "./file-tree-utils" import { isMarkdownFile, MarkdownDiffView } from "./MarkdownDiffView" -import { diffToken } from "./diff-state" +import { createDiffRows, diffToken } from "./diff-state" // --- Data model --- @@ -120,6 +120,7 @@ export const DiffPanel: Component = (props) => { // Reorder diffs to match the file-tree's depth-first visual order so // scrolling through the accordion matches the tree grouping. const sorted = createMemo(() => treeOrder(props.diffs)) + const rows = createDiffRows(sorted, () => props.sessionKey) const eager = createMemo(() => eagerDiffFiles(sorted())) const comments = () => props.comments @@ -484,7 +485,7 @@ export const DiffPanel: Component = (props) => { 0}>
- + {(diff) => { const isAdded = () => diff.status === "added" const isDeleted = () => diff.status === "deleted" diff --git a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx b/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx index d609816367..8920a04850 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx @@ -48,7 +48,7 @@ import { } from "./diff-open-policy" import { DiffEndMarker } from "./DiffEndMarker" import { isMarkdownFile, MarkdownDiffView } from "./MarkdownDiffView" -import { diffToken } from "./diff-state" +import { createDiffRows, diffToken } from "./diff-state" type DiffStyle = "unified" | "split" @@ -136,6 +136,7 @@ export const FullScreenDiffView: Component = (props) => // Reorder diffs to match the file-tree's depth-first visual order so // scrolling through the diff panel matches the tree on the left. const sorted = createMemo(() => treeOrder(props.diffs)) + const rows = createDiffRows(sorted, () => props.sessionKey) const eager = createMemo(() => eagerDiffFiles(sorted())) const comments = () => props.comments @@ -581,7 +582,7 @@ export const FullScreenDiffView: Component = (props) => 0}>
- + {(diff) => { const isAdded = () => diff.status === "added" const isDeleted = () => diff.status === "deleted" diff --git a/packages/kilo-vscode/webview-ui/agent-manager/diff-state.ts b/packages/kilo-vscode/webview-ui/agent-manager/diff-state.ts index e54e156e0f..b6b4a68c20 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/diff-state.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/diff-state.ts @@ -1,3 +1,4 @@ +import { createMemo, createSignal } from "solid-js" import type { WorktreeFileDiff } from "../src/types/messages" export function sameDiffMeta(left: WorktreeFileDiff, right: WorktreeFileDiff) { @@ -18,6 +19,45 @@ export function diffToken(diff: WorktreeFileDiff) { return diff.stamp ?? parts.join(":") } +// Keep each rendered row mounted while live detail refreshes replace its data. +// Otherwise Solid's keyed remounts the row and deferred rendering swaps a +// previously rendered diff above the viewport for a short placeholder. +export function createDiffRows(source: () => WorktreeFileDiff[], key: () => string | undefined) { + const cache = new Map void }>() + let current: string | undefined + + return createMemo(() => { + const nextKey = key() + if (current !== nextKey) { + current = nextKey + cache.clear() + } + + const files = new Set() + const diffs = source().map((next) => { + files.add(next.file) + const current = cache.get(next.file) + if (current) { + current.set(next) + return current.diff + } + + const [value, setValue] = createSignal(next) + const diff = new Proxy(next, { + get: (_, key) => Reflect.get(value(), key), + }) + cache.set(next.file, { diff, set: setValue }) + return diff + }) + + for (const file of cache.keys()) { + if (files.has(file)) continue + cache.delete(file) + } + return diffs + }) +} + export interface MergeResult { diffs: WorktreeFileDiff[] /** Files whose metadata changed while we preserved cached content. diff --git a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx index 1c1fa21f2c..36eca47f4a 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx @@ -15,7 +15,7 @@ import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Icon } from "@kilocode/kilo-ui/icon" import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip" import { ContextMenu } from "@kilocode/kilo-ui/context-menu" -import type { JSX } from "solid-js" +import { createSignal, type JSX } from "solid-js" import type { WorktreeFileDiff, WorktreeState, WorktreeGitStats, PRStatus } from "../types/messages" import "../../agent-manager/agent-manager.css" import "../../agent-manager/agent-manager-review.css" @@ -63,6 +63,48 @@ const foldedDiffs: WorktreeFileDiff[] = [ }, ] +const ROWS = 140 +function edited(seed: string): WorktreeFileDiff { + const before = Array.from({ length: ROWS }, (_, i) => `const row${i} = "${seed}-old-${i}"\n`).join("") + const after = Array.from({ length: ROWS }, (_, i) => `const row${i} = "${seed}-new-${i}"\n`).join("") + const patch = [ + "diff --git a/src/agent-edit.ts b/src/agent-edit.ts", + "--- a/src/agent-edit.ts", + "+++ b/src/agent-edit.ts", + `@@ -1,${ROWS} +1,${ROWS} @@`, + ...before + .trimEnd() + .split("\n") + .map((line) => `-${line}`), + ...after + .trimEnd() + .split("\n") + .map((line) => `+${line}`), + "", + ].join("\n") + + return { + file: "src/agent-edit.ts", + status: "modified", + additions: ROWS, + deletions: ROWS, + before, + after, + patch, + } +} + +const tail: WorktreeFileDiff = { + file: "src/target.ts", + status: "modified", + additions: 1, + deletions: 1, + before: "const target = 'before'\n", + after: "const target = 'after'\n", + patch: + "diff --git a/src/target.ts b/src/target.ts\n--- a/src/target.ts\n+++ b/src/target.ts\n@@ -1 +1 @@\n-const target = 'before'\n+const target = 'after'\n", +} + // --------------------------------------------------------------------------- // Meta // --------------------------------------------------------------------------- @@ -235,6 +277,51 @@ export const FullScreenDiffWithCollapsedContext: Story = { ), } +export const FullScreenDiffAgentEditScroll: Story = { + name: "FullScreenDiffView - preserve scroll during agent edit", + render: () => { + const [diffs, setDiffs] = createSignal([edited("before"), tail]) + const [version, setVersion] = createSignal("before") + const [key, setKey] = createSignal("agent-edit-scroll") + const update = () => { + setDiffs([edited("after"), tail]) + setVersion("after") + } + const change = () => { + setDiffs([edited("context"), tail]) + setKey("changed-context") + } + return ( + +
+
+ + + {version()} + {key()} +
+
+ {}} + comments={[]} + onCommentsChange={() => {}} + onClose={() => {}} + /> +
+
+
+ ) + }, +} + // --------------------------------------------------------------------------- // WorktreeItem — shared mock helpers // --------------------------------------------------------------------------- From c44cd786688a5050805fbd3c17e23ca14a5324a5 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 1 Jun 2026 10:44:36 +0200 Subject: [PATCH 08/12] fix(vscode): surface backend crash recovery --- .changeset/calm-kilos-reconnect.md | 5 ++ packages/kilo-vscode/src/KiloProvider.ts | 18 ++++- .../cli-backend/connection-service.ts | 78 ++++++++++++++----- .../services/cli-backend/server-manager.ts | 7 +- .../prompt-input-connection-guard.test.ts | 20 +++++ .../tests/unit/sdk-sse-adapter.test.ts | 32 ++++++++ .../src/components/chat/PromptInput.tsx | 1 + 7 files changed, 137 insertions(+), 24 deletions(-) create mode 100644 .changeset/calm-kilos-reconnect.md create mode 100644 packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts diff --git a/.changeset/calm-kilos-reconnect.md b/.changeset/calm-kilos-reconnect.md new file mode 100644 index 0000000000..950defda6b --- /dev/null +++ b/.changeset/calm-kilos-reconnect.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Show a retryable connection error and preserve unsent prompts when the VS Code background CLI process exits. diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 19c6316a83..aa44d23b2d 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -347,6 +347,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } + private postConnectionState(error = this.connectionService.getConnectionError()): void { + this.postMessage({ + type: "connectionState", + state: this.connectionState, + ...(this.connectionState === "error" && { + error: getErrorMessage(error) || "Connection to CLI backend lost. Retry to reconnect.", + }), + }) + } + // Strip edit-tool metadata.filediff.before/after (multi-MB for edit-heavy // sessions) to keep session switches fast. Logic in kilo-provider/slim-metadata.ts. private slimPart(part: T): T { @@ -386,7 +396,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } // Always push connection state first so the UI can render appropriately. - this.postMessage({ type: "connectionState", state: this.connectionState }) + this.postConnectionState() pushTelemetryState((m) => this.postMessage(m)) // Re-send ready so the webview can recover after refresh. @@ -1228,9 +1238,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper ) // Subscribe to connection state changes - this.unsubscribeState = this.connectionService.onStateChange(async (state) => { + this.unsubscribeState = this.connectionService.onStateChange(async (state, error) => { this.connectionState = state - this.postMessage({ type: "connectionState", state }) + this.postConnectionState(error) if (state === "connected") { // Fire config warnings independently so a failure in the @@ -1309,7 +1319,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper workspaceDirectory: this.getProjectDirectory(this.currentSession?.id), }) } - this.postMessage({ type: "connectionState", state: this.connectionState }) + this.postConnectionState() // connect() can resolve after SSE reaches "connected" but before this // provider subscribes to onStateChange(). In that case the initial diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts index 768f6bb6c7..caee432af0 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts @@ -7,7 +7,7 @@ import { resolveEventSessionId as resolveEventSessionIdPure } from "./connection export type ConnectionState = "connecting" | "connected" | "disconnected" | "error" type SSEEventListener = (event: Event, directory?: string) => void -type StateListener = (state: ConnectionState) => void +type StateListener = (state: ConnectionState, error?: Error) => void type SSEEventFilter = (event: Event, directory?: string) => boolean type NotificationDismissListener = (notificationId: string) => void type LanguageChangeListener = (locale: string) => void @@ -65,6 +65,7 @@ export class KiloConnectionService { private info: { port: number } | null = null private config: ServerConfig | null = null private state: ConnectionState = "disconnected" + private error: Error | null = null private connectPromise: Promise | null = null private healthPollTimer: ReturnType | null = null private remoteService: import("../RemoteStatusService").RemoteStatusService | null = null @@ -93,7 +94,7 @@ export class KiloConnectionService { private unsubRemote: (() => void) | null = null constructor(context: vscode.ExtensionContext) { - this.serverManager = new ServerManager(context) + this.serverManager = new ServerManager(context, (code) => this.handleServerExit(code)) } /** @@ -115,7 +116,7 @@ export class KiloConnectionService { await this.connectPromise } catch (error) { // If doConnect() fails before SSE can emit a state transition, avoid leaving consumers stuck in "connecting". - this.setState("error") + this.setState("error", this.error ?? (error instanceof Error ? error : new Error(String(error)))) throw error } finally { this.connectPromise = null @@ -126,7 +127,7 @@ export class KiloConnectionService { * Get the shared SDK client. Throws if not connected. */ getClient(): KiloClient { - if (!this.client) { + if (!this.client || this.state !== "connected") { throw new Error("Not connected — call connect() first") } return this.client @@ -139,11 +140,11 @@ export class KiloConnectionService { * or if the connection fails. */ async getClientAsync(dir?: string): Promise { - if (this.client) return this.client + if (this.client && this.state === "connected") return this.client const root = dir ?? vscode.workspace.workspaceFolders?.[0]?.uri.fsPath if (!root) throw new Error("No workspace folder open") await this.connect(root) - return this.client! + return this.getClient() } /** @@ -189,6 +190,13 @@ export class KiloConnectionService { return this.state } + /** + * Last connection error. Cleared when a new connection attempt begins. + */ + getConnectionError(): Error | null { + return this.error + } + /** * Subscribe to SSE events. Returns unsubscribe function. */ @@ -503,12 +511,14 @@ export class KiloConnectionService { this.config = null this.info = null this.state = "disconnected" + this.error = null } - private setState(state: ConnectionState): void { + private setState(state: ConnectionState, error?: Error): void { this.state = state + this.error = state === "error" ? (error ?? this.error) : null for (const listener of this.stateListeners) { - listener(state) + listener(state, this.error ?? undefined) } } @@ -558,10 +568,28 @@ export class KiloConnectionService { } } - private async doConnect(workspaceDir: string): Promise { - // If we reconnect, ensure the previous SSE connection is cleaned up first. + private resetConnection(): void { this.stopHealthPoll() - this.sseClient?.dispose() + const sse = this.sseClient + this.sseClient = null + sse?.disconnect() + this.client = null + this.config = null + this.info = null + } + + private handleServerExit(code: number | null): void { + console.warn("[Kilo New] ConnectionService: CLI background process exited:", code) + this.resetConnection() + this.setState( + "error", + new Error(`CLI background process exited with code ${code ?? "unknown"}. Retry to reconnect.`), + ) + } + + private async doConnect(workspaceDir: string): Promise { + // Never expose a stale SDK client while its replacement server is starting. + this.resetConnection() const server = await this.serverManager.getServer() this.info = { port: server.port } @@ -575,14 +603,15 @@ export class KiloConnectionService { // Create SDK client with Basic Auth header const authHeader = `Basic ${Buffer.from(`kilo:${server.password}`).toString("base64")}` - this.client = createKiloClient({ + const client = createKiloClient({ baseUrl: config.baseUrl, headers: { Authorization: authHeader, }, }) - - this.sseClient = new SdkSSEAdapter(this.client) + const sse = new SdkSSEAdapter(client) + this.client = client + this.sseClient = sse // Wait until SSE yields its first server event before resolving connect(). // Initial stream failures are handled by the adapter reconnect loop. @@ -596,18 +625,29 @@ export class KiloConnectionService { let didConnect = false // Wire SSE events → broadcast to all registered listeners - this.sseClient.onEvent((event, directory) => { + sse.onEvent((event, directory) => { + if (this.sseClient !== sse) return for (const listener of this.eventListeners) { listener(event, directory) } }) - this.sseClient.onError(() => { - this.setState("error") + sse.onError((error) => { + if (this.sseClient !== sse) return + this.setState("error", error) }) // Wire SSE state → broadcast to all registered state listeners - this.sseClient.onStateChange((sseState) => { + sse.onStateChange((sseState) => { + if (this.sseClient !== sse) { + if (!didConnect && sseState === "disconnected") { + rejectConnected?.(new Error(`SSE connection ended in state: ${sseState}`)) + resolveConnected = null + rejectConnected = null + } + return + } + this.setState(sseState) if (sseState === "connected") { @@ -625,7 +665,7 @@ export class KiloConnectionService { } }) - this.sseClient.connect() + sse.connect() await connectedPromise diff --git a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts index 40a132e8ea..9b59da6db3 100644 --- a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts +++ b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts @@ -17,6 +17,7 @@ export interface ServerInstance { const STARTUP_TIMEOUT_SECONDS = 30 type WorkspaceFolderLike = { uri: { fsPath: string } } +type ServerExitListener = (code: number | null) => void export function resolveServerCwd(folders: readonly WorkspaceFolderLike[] | undefined, storage: string): string { return folders?.[0]?.uri.fsPath ?? storage @@ -31,7 +32,10 @@ export class ServerManager { private instance: ServerInstance | null = null private startupPromise: Promise | null = null - constructor(private readonly context: vscode.ExtensionContext) {} + constructor( + private readonly context: vscode.ExtensionContext, + private readonly onExit?: ServerExitListener, + ) {} /** * Get or start the server instance @@ -171,6 +175,7 @@ export class ServerManager { console.log("[Kilo New] ServerManager: 🛑 Process exited with code:", code) if (this.instance?.process === serverProcess) { this.instance = null + this.onExit?.(code) } if (!resolved) { const { userMessage, userDetails } = toErrorMessage( diff --git a/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts b/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts new file mode 100644 index 0000000000..40b434fb17 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/prompt-input-connection-guard.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" + +describe("PromptInput connection guard", () => { + const path = join(__dirname, "..", "..", "webview-ui", "src", "components", "chat", "PromptInput.tsx") + const src = readFileSync(path, "utf8") + + it("rechecks the connection after resolving async attachments and before clearing the draft", () => { + const attachments = src.indexOf("const gitFile = await git.resolveAttachment") + const guard = src.indexOf("if (isDisabled()) return", attachments) + const send = src.indexOf("session.sendMessage(message", guard) + const clear = src.indexOf("drafts.delete(key)", send) + + expect(attachments).toBeGreaterThan(-1) + expect(guard).toBeGreaterThan(attachments) + expect(send).toBeGreaterThan(guard) + expect(clear).toBeGreaterThan(send) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/sdk-sse-adapter.test.ts b/packages/kilo-vscode/tests/unit/sdk-sse-adapter.test.ts index 5f54670d34..6bdc1c15a3 100644 --- a/packages/kilo-vscode/tests/unit/sdk-sse-adapter.test.ts +++ b/packages/kilo-vscode/tests/unit/sdk-sse-adapter.test.ts @@ -110,6 +110,38 @@ describe("SdkSSEAdapter", () => { }) }) +describe("KiloConnectionService backend crash", () => { + it("invalidates the stale SDK client and reports a retryable error", () => { + const service = new KiloConnectionService({} as any) + const states: Array<{ state: string; error?: string }> = [] + ;(service as any).client = {} + ;(service as any).config = { baseUrl: "http://127.0.0.1:52512", password: "secret" } + ;(service as any).info = { port: 52512 } + ;(service as any).state = "connected" + service.onStateChange((state, error) => states.push({ state, error: error?.message })) + ;(service as any).handleServerExit(9) + + expect(service.getConnectionState()).toBe("error") + expect(service.getConnectionError()?.message).toContain("CLI background process exited with code 9") + expect(service.getServerConfig()).toBeNull() + expect(service.getServerInfo()).toBeNull() + expect(() => service.getClient()).toThrow("Not connected") + expect(states).toEqual([ + { state: "error", error: "CLI background process exited with code 9. Retry to reconnect." }, + ]) + service.dispose() + }) + + it("does not expose an SDK client while a replacement server is connecting", () => { + const service = new KiloConnectionService({} as any) + ;(service as any).client = {} + ;(service as any).state = "connecting" + + expect(() => service.getClient()).toThrow("Not connected") + service.dispose() + }) +}) + describe("KiloConnectionService SSE startup", () => { it("waits through an initial SSE fetch failure until the stream opens", async () => { const original = globalThis.fetch diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index 897a10c9f7..898301bc4e 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -801,6 +801,7 @@ export const PromptInput: Component = (props) => { return undefined }) if (hasGit() && hasGitChangesMention(message) && !gitFile) return + if (isDisabled()) return const allFiles = [ ...mentionFiles, From 695d1d0dd35e87076a92bc56d250e5977523246f Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 1 Jun 2026 10:45:20 +0200 Subject: [PATCH 09/12] refactor(vscode): clarify diff row cache names --- .../kilo-vscode/webview-ui/agent-manager/diff-state.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/diff-state.ts b/packages/kilo-vscode/webview-ui/agent-manager/diff-state.ts index b6b4a68c20..4d977e13ce 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/diff-state.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/diff-state.ts @@ -36,15 +36,15 @@ export function createDiffRows(source: () => WorktreeFileDiff[], key: () => stri const files = new Set() const diffs = source().map((next) => { files.add(next.file) - const current = cache.get(next.file) - if (current) { - current.set(next) - return current.diff + const cached = cache.get(next.file) + if (cached) { + cached.set(next) + return cached.diff } const [value, setValue] = createSignal(next) const diff = new Proxy(next, { - get: (_, key) => Reflect.get(value(), key), + get: (_, prop) => Reflect.get(value(), prop), }) cache.set(next.file, { diff, set: setValue }) return diff From d3f8a653a3fd06803d401c91dda44c99b042ddc5 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 1 Jun 2026 10:57:39 +0200 Subject: [PATCH 10/12] test(vscode): update connection service stubs --- packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts | 1 + .../kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts | 1 + .../kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts index 77ec50adf6..975cf56d6b 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts @@ -69,6 +69,7 @@ function connection() { getServerInfo: () => ({ port: 12345 }), getServerConfig: () => ({ baseUrl: "http://127.0.0.1:12345", password: "test" }), getConnectionState: () => "connected" as const, + getConnectionError: () => null, resolveEventSessionId: (event: Event) => (event.type === "session.created" ? event.properties.info.id : undefined), recordMessageSessionId: () => undefined, notifyNotificationDismissed: () => undefined, diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts index 7bde1f4ad9..5db08440c9 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts @@ -98,6 +98,7 @@ function createConnection(client: ReturnType) { registerDirectoryProvider: () => () => undefined, getServerInfo: () => ({ port: 12345 }), getConnectionState: () => "connected" as const, + getConnectionError: () => null, resolveEventSessionId: () => undefined, recordMessageSessionId: () => undefined, notifyNotificationDismissed: () => undefined, diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts index b6e24bd2e8..c4e2219355 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts @@ -91,6 +91,7 @@ function createConnection(client: ReturnType) { getServerInfo: () => ({ port: 12345 }), getServerConfig: () => ({ baseUrl: "http://127.0.0.1:12345", password: "test" }), getConnectionState: () => "connected" as const, + getConnectionError: () => null, resolveEventSessionId: () => undefined, recordMessageSessionId: () => undefined, notifyNotificationDismissed: () => undefined, From e4b808e267d550bf36372b12cad33e3553a5ef6d Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Mon, 1 Jun 2026 08:59:32 +0000 Subject: [PATCH 11/12] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 82e6694687..2e1078f68d 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-KqxasDpdyLQBZC+N85z4HFXLn/15D9qqDyEqgvWTY7c=", - "aarch64-linux": "sha256-YEBF+S2+8imYWO4PfbCY5AOA5uIqAZY80GO6HK8k/sM=", - "aarch64-darwin": "sha256-CxE5cdMcvcAENZLCilCU6Ndx+wiZVbFxtx0UNySdNDA=", - "x86_64-darwin": "sha256-Z9eU77dkavAGAM0AlcnKXCXrrFuRyLnYwM/F9nrkwrk=" + "x86_64-linux": "sha256-/cQ10dEr62YjcG5Fqm1lSHzhssvGPHMeaNHong+y/T4=", + "aarch64-linux": "sha256-RjA9KOoLg0AAzkSg1ApgeW/kUlOMxJhlKuGJwh+jLtE=", + "aarch64-darwin": "sha256-nfWpwSyC4CaU1Ad3PY+3pEzTyITAUVf12cxYsxaHjqk=", + "x86_64-darwin": "sha256-ytU4wj6Ywoe1a6pBldRqXFFoXRaUXNTV/9gTqA3fz3o=" } } From 7dd8aabadeb1b5bcf69f5fb9545a57ac91daf54f Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Mon, 1 Jun 2026 11:50:11 +0200 Subject: [PATCH 12/12] fix(cli): skip background port scans in VS Code --- .changeset/calm-background-process-scans.md | 6 + .../src/kilocode/background-process/index.ts | 5 +- .../test/kilocode/background-process.test.ts | 121 ++++++++++++++++++ 3 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 .changeset/calm-background-process-scans.md diff --git a/.changeset/calm-background-process-scans.md b/.changeset/calm-background-process-scans.md new file mode 100644 index 0000000000..3b32864b9f --- /dev/null +++ b/.changeset/calm-background-process-scans.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Skip inferred background-process port scanning in VS Code sessions to avoid unnecessary Bun subprocess polling. diff --git a/packages/opencode/src/kilocode/background-process/index.ts b/packages/opencode/src/kilocode/background-process/index.ts index b347d67e92..cafef91183 100644 --- a/packages/opencode/src/kilocode/background-process/index.ts +++ b/packages/opencode/src/kilocode/background-process/index.ts @@ -8,6 +8,7 @@ import { SessionID } from "@/session/schema" import { Shell } from "@/shell/shell" import { NonNegativeInt, PositiveInt, optionalOmitUndefined, withStatics } from "@/util/schema" import { zod, ZodOverride } from "@/util/effect-zod" +import { Flag } from "@opencode-ai/core/flag/flag" import * as Log from "@opencode-ai/core/util/log" import { spawn, type ChildProcess } from "child_process" import { Context, Effect, Layer, Schema, Types } from "effect" @@ -172,7 +173,8 @@ export namespace BackgroundProcess { return changed } const fallback = active.info.ready && active.start.ready?.port ? [active.start.ready.port] : [] - const next = Array.from(new Set([...(await Ports.list(pid)), ...fallback])).toSorted((a, b) => a - b) + const ports = Flag.KILO_CLIENT === "cli" ? await Ports.list(pid) : [] + const next = Array.from(new Set([...ports, ...fallback])).toSorted((a, b) => a - b) if (same(active.info.ports, next)) return false active.info.ports = next active.info.time.updated = Date.now() @@ -209,6 +211,7 @@ export namespace BackgroundProcess { function poll(active: Active) { if (active.disposed) return + if (Flag.KILO_CLIENT !== "cli") return if (terminal(active.info.status)) return if (active.poll) return active.poll = setTimeout(() => { diff --git a/packages/opencode/test/kilocode/background-process.test.ts b/packages/opencode/test/kilocode/background-process.test.ts index 911f5ac8a8..0e785e4485 100644 --- a/packages/opencode/test/kilocode/background-process.test.ts +++ b/packages/opencode/test/kilocode/background-process.test.ts @@ -23,6 +23,14 @@ async function script(dir: string, name: string, source: string) { return `${bin} ${arg}` } +function port() { + const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() }) + const port = server.port + server.stop(true) + if (!port) throw new Error("Failed to reserve port") + return port +} + function update(sessionID: SessionID) { const state: { off?: () => void; timer?: ReturnType } = {} const promise = new Promise((resolve, reject) => { @@ -92,6 +100,119 @@ setInterval(() => {}, 1_000) }), ) + it.instance("reports explicit readiness ports for VS Code clients", () => + Effect.gen(function* () { + const test = yield* TestInstance + const sessionID = SessionID.descending() + const listen = port() + const command = yield* Effect.promise(() => + script( + test.directory, + "vscode-ready-port.mjs", + `Bun.serve({ hostname: "127.0.0.1", port: ${listen}, fetch: () => new Response() }) +`, + ), + ) + const client = process.env["KILO_CLIENT"] + process.env["KILO_CLIENT"] = "vscode" + + try { + const info = yield* Effect.promise(() => + BackgroundProcess.start({ + sessionID, + command, + cwd: test.directory, + ready: { port: listen, timeout: 5_000 }, + }), + ) + + expect(info.status).toBe("ready") + expect(info.ports).toEqual([listen]) + } finally { + yield* Effect.promise(() => BackgroundProcess.stopSession(sessionID)) + if (client === undefined) delete process.env["KILO_CLIENT"] + else process.env["KILO_CLIENT"] = client + } + }), + ) + + it.instance("infers ports for CLI clients", () => + Effect.gen(function* () { + const test = yield* TestInstance + const sessionID = SessionID.descending() + const listen = port() + const command = yield* Effect.promise(() => + script( + test.directory, + "cli-port.mjs", + `Bun.serve({ hostname: "127.0.0.1", port: ${listen}, fetch: () => new Response() }) +`, + ), + ) + const client = process.env["KILO_CLIENT"] + process.env["KILO_CLIENT"] = "cli" + + try { + const info = yield* Effect.promise(() => + BackgroundProcess.start({ + sessionID, + command, + cwd: test.directory, + }), + ) + + let found = yield* Effect.promise(() => BackgroundProcess.get(info.id)) + if (process.platform !== "win32") { + for (let attempt = 0; attempt < 40 && !found?.ports.includes(listen); attempt++) { + yield* Effect.promise(() => Bun.sleep(250)) + found = yield* Effect.promise(() => BackgroundProcess.get(info.id)) + } + } + expect(found?.ports).toEqual(process.platform === "win32" ? [] : [listen]) + } finally { + yield* Effect.promise(() => BackgroundProcess.stopSession(sessionID)) + if (client === undefined) delete process.env["KILO_CLIENT"] + else process.env["KILO_CLIENT"] = client + } + }), + ) + + it.instance("does not infer ports for VS Code clients", () => + Effect.gen(function* () { + const test = yield* TestInstance + const sessionID = SessionID.descending() + const listen = port() + const command = yield* Effect.promise(() => + script( + test.directory, + "vscode-port.mjs", + `Bun.serve({ hostname: "127.0.0.1", port: ${listen}, fetch: () => new Response() }) +`, + ), + ) + const client = process.env["KILO_CLIENT"] + process.env["KILO_CLIENT"] = "vscode" + + try { + const info = yield* Effect.promise(() => + BackgroundProcess.start({ + sessionID, + command, + cwd: test.directory, + }), + ) + + yield* Effect.promise(() => Bun.sleep(2_500)) + const found = yield* Effect.promise(() => BackgroundProcess.get(info.id)) + expect(found?.ports).toEqual([]) + } finally { + yield* Effect.promise(() => BackgroundProcess.stopSession(sessionID)) + if (client === undefined) delete process.env["KILO_CLIENT"] + else process.env["KILO_CLIENT"] = client + } + }), + ) + it.instance("publishes output updates from process callbacks", () => Effect.gen(function* () { const test = yield* TestInstance