From becf9372f81766cd0b29a1a891ce6d52e62d87d7 Mon Sep 17 00:00:00 2001 From: Johnny Eric Amancio Date: Mon, 10 Aug 2026 13:14:56 +0200 Subject: [PATCH 1/2] fix: address merge review findings and CI timeouts --- .../src/agent-manager/AgentManagerProvider.ts | 9 +++- .../src/agent-manager/focus-panel.ts | 9 +--- .../unit/agent-manager-focus-panel.test.ts | 52 +++++++++++++++++++ .../agent-manager/AgentManagerApp.tsx | 1 - packages/opencode/src/provider/provider.ts | 8 +-- .../kilocode/server/config-overlay.test.ts | 2 +- .../kilocode/session-prompt-queue.test.ts | 2 +- 7 files changed, 68 insertions(+), 15 deletions(-) create mode 100644 packages/kilo-vscode/tests/unit/agent-manager-focus-panel.test.ts diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 2594aa9fbf8..321881117fe 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -355,7 +355,10 @@ export class AgentManagerProvider implements Disposable { public openPanel(preserveFocus?: boolean): void { if (this.panel) { this.log("Panel already open, revealing") - revealPanel(this.panel, preserveFocus, this.waitForPanelReady(this.panel), this.waitForPanelActive(this.panel)) + const panel = this.panel + revealPanel(panel, preserveFocus, () => + focusPanelPrompt(panel, this.waitForPanelReady(panel), this.waitForPanelActive(panel)), + ) return } this.log("Opening Agent Manager panel") @@ -1695,7 +1698,9 @@ export class AgentManagerProvider implements Disposable { public focusPanel(): void { const panel = this.panel if (!panel) return - revealPanel(panel, false, this.waitForPanelReady(panel), this.waitForPanelActive(panel)) + revealPanel(panel, false, () => + focusPanelPrompt(panel, this.waitForPanelReady(panel), this.waitForPanelActive(panel)), + ) } public isActive(): boolean { return this.panel?.active === true diff --git a/packages/kilo-vscode/src/agent-manager/focus-panel.ts b/packages/kilo-vscode/src/agent-manager/focus-panel.ts index a8c83ec9191..03e31ab28f9 100644 --- a/packages/kilo-vscode/src/agent-manager/focus-panel.ts +++ b/packages/kilo-vscode/src/agent-manager/focus-panel.ts @@ -7,12 +7,7 @@ export function focusPanelPrompt(panel: PanelContext, ready: Promise, a }) } -export function revealPanel( - panel: PanelContext, - preserve: boolean | undefined, - ready: Promise, - active: Promise, -): void { +export function revealPanel(panel: PanelContext, preserve: boolean | undefined, focus?: () => void): void { panel.reveal(preserve) - if (!preserve) focusPanelPrompt(panel, ready, active) + if (!preserve) focus?.() } diff --git a/packages/kilo-vscode/tests/unit/agent-manager-focus-panel.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-focus-panel.test.ts new file mode 100644 index 00000000000..d7817635cbb --- /dev/null +++ b/packages/kilo-vscode/tests/unit/agent-manager-focus-panel.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, mock } from "bun:test" +import { focusPanelPrompt, revealPanel } from "../../src/agent-manager/focus-panel" +import type { PanelContext } from "../../src/agent-manager/host" + +describe("revealPanel", () => { + it("reveals the panel without calling focus when preserve is true", () => { + const revealed: Array = [] + const panel = { + reveal: (preserve?: boolean) => revealed.push(preserve), + postMessage: mock(() => Promise.resolve(true)), + } as unknown as PanelContext + + let focused = false + revealPanel(panel, true, () => { + focused = true + }) + + expect(revealed).toEqual([true]) + expect(focused).toBe(false) + }) + + it("calls the focus callback when preserve is false or undefined", () => { + const revealed: Array = [] + const panel = { + reveal: (preserve?: boolean) => revealed.push(preserve), + postMessage: mock(() => Promise.resolve(true)), + } as unknown as PanelContext + + let focused = false + revealPanel(panel, false, () => { + focused = true + }) + + expect(revealed).toEqual([false]) + expect(focused).toBe(true) + }) + + it("sends focusInput message when panel is ready and active", async () => { + const messages: unknown[] = [] + const panel = { + postMessage: (msg: unknown) => { + messages.push(msg) + return Promise.resolve(true) + }, + } as unknown as PanelContext + + focusPanelPrompt(panel, Promise.resolve(true), Promise.resolve(true)) + await new Promise((r) => setTimeout(r, 10)) + + expect(messages).toEqual([{ type: "action", action: "focusInput" }]) + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 6cc33b05b7c..dbc84af2e2b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -198,7 +198,6 @@ interface SetupState { type SidebarSelection = typeof LOCAL | string | null type SidePanel = "diff" | "pr" | "terminal" | null const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) -const SIDE_RESIZE_INTERVAL_MS = 32 import { parseBindingTokens } from "./keybind-tokens" import { defaultBindings } from "./keybind-defaults" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index bc27c62e56f..b03526a5c93 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1553,9 +1553,11 @@ const layer = Layer.effect( existingModel?.api.npm === m.api.npm ? (existingModel.variants ?? ProviderTransform.variants(m)) : ProviderTransform.variants(m) - const generated = Object.keys(model.variants ?? {}).length - ? {} - : customProviderVariants(parsedModel, model.provider?.npm ?? provider.npm, baseGenerate) + const generated = customProviderVariants( + parsedModel, + model.provider?.npm ?? provider.npm, + baseGenerate, + ) const merged = mergeDeep(generated, model.variants ?? {}) // kilocode_change end parsedModel.variants = mapValues( diff --git a/packages/opencode/test/kilocode/server/config-overlay.test.ts b/packages/opencode/test/kilocode/server/config-overlay.test.ts index f87246fb360..691e9903892 100644 --- a/packages/opencode/test/kilocode/server/config-overlay.test.ts +++ b/packages/opencode/test/kilocode/server/config-overlay.test.ts @@ -911,7 +911,7 @@ describe("config overlay routes", () => { Permission.evaluate("edit", "*", after.find((item) => item.name === "code")?.permission ?? []).action, ).toBe("allow") }, - 30_000, + 60_000, ) } }) diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index 1bd64ffc718..58afffcf437 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -555,7 +555,7 @@ describe("session prompt queue", () => { } finally { server.stop(true) } - }) + }, 30_000) test("closes a queued-handoff turn as superseded, not interrupted", async () => { const ready = Promise.withResolvers() From 73844ae68d27d095681c2369a8093156b106a453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Mon, 10 Aug 2026 12:15:30 +0100 Subject: [PATCH 2/2] feat(cli): answer sessionless model catalog requests (#13014) * feat(cli): answer sessionless model catalog requests * feat(cli): add sessionless catalog changeset * chore(cli): format remote sender files * test(cli): simplify sessionless catalog coverage * fix(test): extend config overlay timeout The legacy overlay test can exceed 30 seconds on a cold Windows runner while its assertions remain valid. --- .changeset/remote-instance-catalog.md | 5 ++ .../src/kilo-sessions/remote-sender.ts | 27 +++---- .../kilocode/server/config-overlay.test.ts | 4 +- .../kilocode/sessions/remote-sender.test.ts | 79 ++++++++++++++++--- 4 files changed, 90 insertions(+), 25 deletions(-) create mode 100644 .changeset/remote-instance-catalog.md diff --git a/.changeset/remote-instance-catalog.md b/.changeset/remote-instance-catalog.md new file mode 100644 index 00000000000..bed43e8cd31 --- /dev/null +++ b/.changeset/remote-instance-catalog.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Support browsing an instance's model catalog before a session starts. diff --git a/packages/opencode/src/kilo-sessions/remote-sender.ts b/packages/opencode/src/kilo-sessions/remote-sender.ts index 288be081c43..d49babd8699 100644 --- a/packages/opencode/src/kilo-sessions/remote-sender.ts +++ b/packages/opencode/src/kilo-sessions/remote-sender.ts @@ -345,9 +345,7 @@ export namespace RemoteSender { // bus listener count from inflating for senders that never handle // attachments (the count would otherwise show up in unrelated tests // that assert it stays at 0). - const attachments = - options.attachments ?? - ((sessionID: SessionID) => RemoteAttachments.create({ sessionID })) + const attachments = options.attachments ?? ((sessionID: SessionID) => RemoteAttachments.create({ sessionID })) const attachmentCache = new Map() const pending = new Map() const retired = new Map() @@ -886,27 +884,27 @@ export namespace RemoteSender { return } // kilocode_change end + // kilocode_change start - sessionless list_models for the pre-session instance picker if (msg.command === "list_models") { const parsed = RemoteModelCatalog.Request.safeParse(msg.data) - const session = msg.sessionId ? decodeSessionID(msg.sessionId) : Option.none() - if (!parsed.success || Option.isNone(session)) { - options.conn.send({ - type: "response", - id: msg.id, - error: "invalid list_models command", - }) + // Accept an absent sessionId (the mobile instance-picker path asks for the + // instance's catalog before a session exists). A present but undecodable + // sessionId is still invalid. + const target = msg.sessionId ? decodeSessionID(msg.sessionId) : Option.none() + if (!parsed.success || (msg.sessionId && Option.isNone(target))) { + options.conn.send({ type: "response", id: msg.id, error: "invalid list_models command" }) return } const run = options.provide ?? provide void (async () => { try { - const info = await catalog.get(session.value) + const info = Option.isSome(target) ? await catalog.get(target.value) : undefined const result = await run({ - directory: info.directory, + directory: info?.directory ?? options.directory, fn: async () => { const [providers, messages, fallback] = await Promise.all([ catalog.providers(), - catalog.messages(info.id), + info ? catalog.messages(info.id) : Promise.resolve([]), catalog.default().catch((err) => { options.log.warn("default model lookup failed", { error: String(err) }) return undefined @@ -914,7 +912,7 @@ export namespace RemoteSender { ]) return RemoteModelCatalog.build({ providers, - session: info, + session: info ?? {}, messages, defaultModel: fallback, }) @@ -928,6 +926,7 @@ export namespace RemoteSender { })() return } + // kilocode_change end if (msg.command === "send_message") { const parsed = getRemotePromptInput().safeParse(msg.data) if (!parsed.success) { diff --git a/packages/opencode/test/kilocode/server/config-overlay.test.ts b/packages/opencode/test/kilocode/server/config-overlay.test.ts index f87246fb360..50c8e1667a5 100644 --- a/packages/opencode/test/kilocode/server/config-overlay.test.ts +++ b/packages/opencode/test/kilocode/server/config-overlay.test.ts @@ -911,7 +911,9 @@ describe("config overlay routes", () => { Permission.evaluate("edit", "*", after.find((item) => item.name === "code")?.permission ?? []).action, ).toBe("allow") }, - 30_000, + // Cold Windows CI runs take ~32s (observed timeout at 30s); give the two + // instance create/dispose cycles of each iteration real headroom. + 90_000, ) } }) diff --git a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts index e8683fe49e8..44504702f37 100644 --- a/packages/opencode/test/kilocode/sessions/remote-sender.test.ts +++ b/packages/opencode/test/kilocode/sessions/remote-sender.test.ts @@ -1084,7 +1084,7 @@ describe("RemoteSender", () => { expect(JSON.stringify(sent)).not.toContain("api-key") }) - test("list_models rejects unsupported versions and missing session IDs", () => { + test("list_models rejects unsupported versions and undecodable session IDs", () => { const { conn, sent } = fakeConn() const sender = RemoteSender.create({ conn, @@ -1100,12 +1100,6 @@ describe("RemoteSender", () => { sessionId: "ses_models", data: { protocolVersion: 2 }, }) - sender.handle({ - type: "command", - id: "req_models_missing_session", - command: "list_models", - data: { protocolVersion: 1 }, - }) sender.handle({ type: "command", id: "req_models_invalid_session", @@ -1116,11 +1110,72 @@ describe("RemoteSender", () => { expect(sent).toEqual([ { type: "response", id: "req_models_v2", error: "invalid list_models command" }, - { type: "response", id: "req_models_missing_session", error: "invalid list_models command" }, { type: "response", id: "req_models_invalid_session", error: "invalid list_models command" }, ]) }) + test("list_models without a sessionId returns the instance catalog", async () => { + const { conn, sent } = fakeConn() + const dirs: string[] = [] + const sender = RemoteSender.create({ + conn, + directory: "/tmp/process-default", + log: nolog, + subscribe: fakeBus().subscribe, + provide: async (input: { directory: string; init?: Effect.Effect; fn: () => R }) => { + dirs.push(input.directory) + return input.fn() + }, + catalog: { + get: async () => { + throw new Error("catalog.get must not be called without a sessionId") + }, + messages: async () => { + throw new Error("catalog.messages must not be called without a sessionId") + }, + providers: async () => + ({ + custom: { + id: ProviderV2.ID.make("custom"), + name: "Custom Provider", + source: "config", + env: ["PRIVATE_API_KEY"], + key: "must-not-leak", + options: { apiKey: "must-not-leak" }, + models: { + "deployment/model": catalogModel("custom", "deployment/model", "Deployment Model", true), + }, + }, + }) as any, + default: async () => ({ + providerID: ProviderV2.ID.make("custom"), + modelID: ModelV2.ID.make("deployment/model"), + }), + }, + }) + + sender.handle({ + type: "command", + id: "req_models_sessionless", + command: "list_models", + data: { protocolVersion: 1 }, + }) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(dirs).toEqual(["/tmp/process-default"]) + expect(sent).toHaveLength(1) + expect(sent[0]?.type).toBe("response") + expect(sent[0]?.id).toBe("req_models_sessionless") + const result = sent[0]?.result as RemoteModelCatalog.Response + expect(result.protocolVersion).toBe(1) + expect(result.all).toHaveLength(1) + expect(result.all[0]?.id).toBe("custom") + expect(result.defaultModel).toEqual({ providerID: "custom", modelID: "deployment/model" }) + expect(result).not.toHaveProperty("currentModel") + expect(JSON.stringify(result)).not.toContain("must-not-leak") + }) + test("send_message with agent is accepted", async () => { const { conn, sent } = fakeConn() let resolveProvide: () => void @@ -3066,7 +3121,9 @@ describe("RemoteSender slash commands", () => { removeCalls.push(id) }, }, - attachSession: async () => { throw new Error("attach failed") }, + attachSession: async () => { + throw new Error("attach failed") + }, }) const response = expectResponse(conn, sent, "req_spawn_failed") @@ -3107,7 +3164,9 @@ describe("RemoteSender slash commands", () => { throw new Error("cleanup secondary failure") }, }, - attachSession: async () => { throw new Error("attach failed") }, + attachSession: async () => { + throw new Error("attach failed") + }, }) const response = expectResponse(conn, sent, "req_spawn_then_cleanup_fail")