Merge branch 'main' into savory-act

This commit is contained in:
Joshua Lambert
2026-06-01 08:44:57 -04:00
committed by GitHub
28 changed files with 587 additions and 48 deletions
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Show a retryable connection error and preserve unsent prompts when the VS Code background CLI process exits.
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Preserve the Changes review scroll position while agents update files.
+1 -7
View File
@@ -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=="],
+4 -4
View File
@@ -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="
}
}
+1 -1
View File
@@ -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",
+14 -4
View File
@@ -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<T>(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
@@ -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<void> | null = null
private healthPollTimer: ReturnType<typeof setInterval> | 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<KiloClient> {
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<void> {
// 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<void> {
// 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
@@ -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<ServerInstance> | 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(
@@ -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)
})
@@ -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,
@@ -98,6 +98,7 @@ function createConnection(client: ReturnType<typeof createClient>) {
registerDirectoryProvider: () => () => undefined,
getServerInfo: () => ({ port: 12345 }),
getConnectionState: () => "connected" as const,
getConnectionError: () => null,
resolveEventSessionId: () => undefined,
recordMessageSessionId: () => undefined,
notifyNotificationDismissed: () => undefined,
@@ -91,6 +91,7 @@ function createConnection(client: ReturnType<typeof createClient>) {
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,
@@ -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)
})
})
@@ -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
@@ -50,6 +50,7 @@ async function disableAnimations(page: Page) {
// Permission dock config-preloaded has non-deterministic toggle rendering.
const SKIP = new Set<string>([
"agentmanager--worktree-item-busy",
"agentmanager--full-screen-diff-agent-edit-scroll",
"composite-webview--permission-dock-config-preloaded",
])
@@ -50,6 +50,7 @@ async function disableAnimations(page: Page) {
// Permission dock config-preloaded has non-deterministic toggle rendering.
const SKIP = new Set<string>([
"agentmanager--worktree-item-busy",
"agentmanager--full-screen-diff-agent-edit-scroll",
"agentmanager--pr-badge-checks-pending",
"composite-webview--permission-dock-config-preloaded",
])
@@ -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<DiffPanelProps> = (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<DiffPanelProps> = (props) => {
<Show when={props.diffs.length > 0}>
<div class="am-diff-content" data-component="session-review" ref={scroller}>
<Accordion multiple value={open()} onChange={setOpen}>
<For each={sorted()}>
<For each={rows()}>
{(diff) => {
const isAdded = () => diff.status === "added"
const isDeleted = () => diff.status === "deleted"
@@ -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<FullScreenDiffViewProps> = (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<FullScreenDiffViewProps> = (props) =>
<Show when={props.diffs.length > 0}>
<div class="am-review-diff-content" data-component="session-review">
<Accordion multiple value={open()} onChange={setOpen}>
<For each={sorted()}>
<For each={rows()}>
{(diff) => {
const isAdded = () => diff.status === "added"
const isDeleted = () => diff.status === "deleted"
@@ -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 <For> 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<string, { diff: WorktreeFileDiff; set: (diff: WorktreeFileDiff) => void }>()
let current: string | undefined
return createMemo(() => {
const nextKey = key()
if (current !== nextKey) {
current = nextKey
cache.clear()
}
const files = new Set<string>()
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.
@@ -801,6 +801,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return undefined
})
if (hasGit() && hasGitChangesMention(message) && !gitFile) return
if (isDisabled()) return
const allFiles = [
...mentionFiles,
@@ -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 (
<StoryProviders noPadding>
<div style={{ height: "700px", display: "flex", "flex-direction": "column" }}>
<div style={{ display: "flex", gap: "8px", padding: "4px", "align-items": "center" }}>
<Button size="small" onClick={update}>
Apply agent edit
</Button>
<Button size="small" onClick={change}>
Switch review context
</Button>
<span data-testid="agent-edit-version">{version()}</span>
<span data-testid="review-context">{key()}</span>
</div>
<div style={{ display: "flex", "min-height": "0", flex: "1" }}>
<FullScreenDiffView
diffs={diffs()}
loading={false}
sessionKey={key()}
diffStyle="unified"
onDiffStyleChange={() => {}}
comments={[]}
onCommentsChange={() => {}}
onClose={() => {}}
/>
</div>
</div>
</StoryProviders>
)
},
}
// ---------------------------------------------------------------------------
// WorktreeItem — shared mock helpers
// ---------------------------------------------------------------------------
@@ -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(() => {
@@ -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,
}
+12 -4
View File
@@ -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<string, Record<string, a
if (adaptiveEfforts) {
let efforts = [...adaptiveEfforts]
if (model.providerID === "github-copilot") {
if (model.api.id.includes("opus-4.7")) {
// kilocode_change start - treat opus-4.8 like opus-4.7
if (model.api.id.includes("opus-4.7") || model.api.id.includes("opus-4.8")) {
efforts = ["medium"]
}
// kilocode_change end
// Efforts currently supported are: low, medium, high
efforts = efforts.filter((v) => v !== "max" && v !== "xhigh")
}
@@ -792,9 +796,11 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
{
thinking: {
type: "adaptive",
...(model.api.id.includes("opus-4-7") || model.api.id.includes("opus-4.7")
// 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 end
},
effort,
},
@@ -827,9 +833,11 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
reasoningConfig: {
type: "adaptive",
maxReasoningEffort: effort,
...(model.api.id.includes("opus-4-7") || model.api.id.includes("opus-4.7")
// 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 end
},
},
]),
@@ -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<typeof setTimeout> } = {}
const promise = new Promise<BackgroundProcess.Info>((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
@@ -30,7 +30,7 @@ function mockModel(overrides: Partial<any> = {}): 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: {