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/.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/.changeset/opus-4-8-adaptive-thinking.md b/.changeset/opus-4-8-adaptive-thinking.md new file mode 100644 index 0000000000..30b7026287 --- /dev/null +++ b/.changeset/opus-4-8-adaptive-thinking.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +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/.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/bun.lock b/bun.lock index 80b17bbd65..b1454733b4 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:*", @@ -4520,8 +4520,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=="], @@ -5092,8 +5090,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=="], @@ -5466,8 +5462,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/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=" } } 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", 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/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/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, 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/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..4d977e13ce 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 cached = cache.get(next.file) + if (cached) { + cached.set(next) + return cached.diff + } + + const [value, setValue] = createSignal(next) + const diff = new Proxy(next, { + get: (_, prop) => Reflect.get(value(), prop), + }) + 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/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, 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 // --------------------------------------------------------------------------- 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/src/plugin/github-copilot/models.ts b/packages/opencode/src/plugin/github-copilot/models.ts index acccf146d0..286e7dc59e 100644 --- a/packages/opencode/src/plugin/github-copilot/models.ts +++ b/packages/opencode/src/plugin/github-copilot/models.ts @@ -125,7 +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") ? { display: "summarized" } : {}), + // kilocode_change start - treat opus-4.8 like opus-4.7 + ...(model.api.id.includes("opus-4.7") || model.api.id.includes("opus-4.8") + ? { display: "summarized" } + : {}), + // kilocode_change end }, effort, } diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index ab7a81ec81..e6f157c18e 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -542,9 +542,11 @@ 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))) { + // 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))) { 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"] } @@ -780,9 +782,11 @@ export function variants(model: Provider.Model): Record v !== "max" && v !== "xhigh") } @@ -792,9 +796,11 @@ export function variants(model: Provider.Model): Record model.api.id.includes(v)) ? { display: "summarized" } : {}), + // kilocode_change end }, effort, }, @@ -827,9 +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/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 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..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: { @@ -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: {