Merge branch 'main' into acidic-fennel

This commit is contained in:
Marius
2026-08-18 09:19:34 +02:00
committed by GitHub
22 changed files with 1073 additions and 143 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep file mention suggestions current and scoped to the active workspace while preserving instant cached results.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Switch to the code model when starting implementation after a planning session.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Preserve cached reasoning variants when starting new VS Code sessions.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Fix agent mode switch getting stuck on draft/cloud-import sessions: apply the explicitly selected agent instead of reverting to the stale session agent.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Show the model provider next to every model in the model selector.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep queued prompts visible while the server confirms and loads their message parts.
@@ -2,7 +2,7 @@ import * as path from "path"
import * as vscode from "vscode"
import type { KiloClient } from "@kilocode/sdk/v2/client"
import { mergeFileSearchResults } from "./file-search-results"
import { mergeFileSearchItems } from "./file-search-items"
import { mergeFileSearchItems, type FileSearchItem } from "./file-search-items"
type Message = {
query: string
@@ -20,6 +20,35 @@ type Input = {
post: (message: unknown) => void
}
async function fetchBackend(client: KiloClient, dir: string, query: string): Promise<[string[], string[]]> {
if (!client?.find?.files) return [[], []]
const [fileRes, folderRes] = await Promise.allSettled([
client.find.files({ query, directory: dir, type: "file", limit: 50 }, { throwOnError: true }),
client.find.files({ query, directory: dir, type: "directory", limit: 50 }, { throwOnError: true }),
])
return [settled(fileRes, "file"), settled(folderRes, "folder")]
}
function assemble(
query: string,
dir: string,
files: string[],
folders: string[],
open: Set<string>,
): { paths: string[]; items: FileSearchItem[] } {
const uri = vscode.window.activeTextEditor?.document.uri
const rel = uri?.scheme === "file" && dir ? path.relative(dir, uri.fsPath) : undefined
const active = rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel.replaceAll("\\", "/") : undefined
const paths = mergeFileSearchResults({ query, backend: files, open, active })
const items = mergeFileSearchItems({
query,
files: paths,
folders,
open: new Set(active ? [active, ...open] : open),
})
return { paths, items }
}
export async function handleFileSearch(input: Input): Promise<void> {
const client = input.client
if (!client) {
@@ -29,27 +58,11 @@ export async function handleFileSearch(input: Input): Promise<void> {
const id = input.message.sessionID ?? input.current ?? input.context
const dir = input.dir(id)
const open = dir ? await input.open(dir) : new Set<string>()
const query = input.message.query
void Promise.allSettled([
client.find.files({ query, directory: dir, type: "file", limit: 50 }, { throwOnError: true }),
client.find.files({ query, directory: dir, type: "directory", limit: 50 }, { throwOnError: true }),
]).then(([fileRes, folderRes]) => {
const files = settled(fileRes, "file")
const folders = settled(folderRes, "folder")
const uri = vscode.window.activeTextEditor?.document.uri
const rel = uri?.scheme === "file" && dir ? path.relative(dir, uri.fsPath) : undefined
const active = rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel.replaceAll("\\", "/") : undefined
const result = mergeFileSearchResults({ query, backend: files, open, active })
const items = mergeFileSearchItems({
query,
files: result,
folders,
open: new Set(active ? [active, ...open] : open),
})
input.post({ type: "fileSearchResult", paths: result, items, dir, requestId: input.message.requestId })
})
const [files, folders] = await fetchBackend(client, dir, query)
const open = dir ? await input.open(dir) : new Set<string>()
const { paths, items } = assemble(query, dir, files, folders, open)
input.post({ type: "fileSearchResult", paths, items, dir, requestId: input.message.requestId })
}
function settled(result: PromiseSettledResult<{ data: string[] }>, kind: "file" | "folder"): string[] {
@@ -26,6 +26,8 @@ test("model selector exposes combobox relationships and active option movement",
await expect(combobox).toHaveAttribute("aria-controls", await tree.getAttribute("id"))
await expect(combobox).toHaveAttribute("aria-activedescendant", await alpha.getAttribute("id"))
await expect(combobox).toHaveAccessibleDescription("Choose the model used for code review tasks.")
await expect(alpha.locator(".model-selector-item-provider-tag")).toHaveText("Kilo")
await expect(bravo.locator(".model-selector-item-provider-tag")).toHaveText("Kilo")
await expect(alpha.locator("button")).toHaveCount(0)
await expect(page.getByRole("button", { name: "Add to favorites: Alpha" })).toBeVisible()
await expect(page.locator(".model-selector-group-label").nth(0)).toContainText("Auto Models")
@@ -114,7 +116,7 @@ test("provider groups collapse, expand, and skip their model rows", async ({ pag
await page.getByRole("button", { name: "Review model: Alpha" }).click()
const combobox = page.getByRole("combobox", { name: "Review model: Alpha. Search models" })
const kilo = page.getByRole("treeitem", { name: "Kilo", exact: true })
const nvidia = page.getByRole("treeitem", { name: "NVIDIA" })
const nvidia = page.getByRole("treeitem", { name: "NVIDIA", exact: true })
await combobox.press("ArrowDown")
await combobox.press("ArrowLeft")
@@ -0,0 +1,74 @@
import { describe, expect, it } from "bun:test"
import { handleFileSearch } from "../../src/kilo-provider/file-search"
type Query = { query: string; directory: string; type: "file" | "directory"; limit: number }
function client(data: { files: string[]; folders: string[] }) {
const calls: Query[] = []
return {
calls,
value: {
find: {
files: async (query: Query) => {
calls.push(query)
return { data: query.type === "file" ? data.files : data.folders }
},
},
},
}
}
describe("handleFileSearch", () => {
it("posts one fresh response for each request", async () => {
const api = client({ files: ["src/a.ts"], folders: ["src"] })
const posted: unknown[] = []
await handleFileSearch({
client: api.value as never,
message: { query: "", requestId: "request-1", sessionID: "session-1" },
dir: (id) => (id === "session-1" ? "/repo" : ""),
open: async () => new Set(["src/open.ts"]),
post: (message) => posted.push(message),
})
expect(api.calls).toEqual([
{ query: "", directory: "/repo", type: "file", limit: 50 },
{ query: "", directory: "/repo", type: "directory", limit: 50 },
])
expect(posted).toHaveLength(1)
expect(posted[0]).toEqual({
type: "fileSearchResult",
requestId: "request-1",
dir: "/repo",
paths: ["src/open.ts", "src/a.ts"],
items: [
{ path: "src/open.ts", type: "opened-file" },
{ path: "src/a.ts", type: "file" },
{ path: "src", type: "folder" },
],
})
})
it("returns an empty fresh response when files were deleted", async () => {
const api = client({ files: [], folders: [] })
const posted: unknown[] = []
await handleFileSearch({
client: api.value as never,
message: { query: "", requestId: "request-empty" },
dir: () => "/repo",
open: async () => new Set(),
post: (message) => posted.push(message),
})
expect(posted).toEqual([
{
type: "fileSearchResult",
requestId: "request-empty",
dir: "/repo",
paths: [],
items: [],
},
])
})
})
@@ -89,6 +89,16 @@ describe("sendCommand dismisses pending tool requests", () => {
})
})
describe("confirmed queued prompts retain optimistic parts", () => {
const source = readFile(SESSION_FILE)
const body = extractFunctionBody(source, "handleMessageCreated")
it("does not clear optimistic parts before canonical part events arrive", () => {
expect(body).toContain("Keep placeholder parts until their canonical part.updated events arrive")
expect(body).not.toContain("delete p[message.id]")
})
})
describe("static command completion contract", () => {
const source = readFile(SESSION_FILE)
@@ -146,6 +146,14 @@ describe("resolvePromptAgent", () => {
expect(resolvePromptAgent({ selections: {}, pending: "code" })).toBe("code")
})
it("honors an explicit pending selection for a draft scope with no per-session entry", () => {
expect(resolvePromptAgent({ sessionID: "draft-1", selections: {}, pending: "ask" })).toBe("ask")
})
it("does not fall back to pending for a real server session with no per-session entry", () => {
expect(resolvePromptAgent({ sessionID: "ses_1", selections: {}, pending: "ask" })).toBeUndefined()
})
it("omits the agent when there is no explicit selection", () => {
expect(resolvePromptAgent({ sessionID: "ses_1", selections: {}, pending: null })).toBeUndefined()
expect(resolvePromptAgent({ selections: {}, pending: null })).toBeUndefined()
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test"
import { mergeParts, sameParts } from "../../webview-ui/src/context/session-parts"
import { mergeOptimisticPart, mergeParts, sameParts } from "../../webview-ui/src/context/session-parts"
import type { Part } from "../../webview-ui/src/types/messages"
function text(id: string, value: string, time: { start?: number; end?: number } = {}): Part {
@@ -10,6 +10,10 @@ function tool(id: string): Part {
return { id, messageID: "m1", type: "tool", tool: "bash", state: { status: "pending", input: {} } }
}
function file(id: string): Part {
return { id, messageID: "m1", type: "file", mime: "text/plain", url: "data:,file" }
}
function value(parts: Part[], id: string) {
const part = parts.find((item) => item.id === id)
if (!part || part.type !== "text") return
@@ -100,6 +104,24 @@ describe("mergeParts", () => {
})
})
describe("mergeOptimisticPart", () => {
it("replaces the optimistic user part when its canonical event arrives", () => {
const current = [text("client", "queued prompt")]
const result = mergeOptimisticPart(current, new Set(["client"]), text("server", "queued prompt"))
expect(result.parts).toEqual([text("server", "queued prompt")])
expect(result.replaced).toBe("client")
})
it("keeps unmatched optimistic parts while canonical attachments arrive", () => {
const current = [text("client-text", "queued prompt"), file("client-file")]
const result = mergeOptimisticPart(current, new Set(["client-text", "client-file"]), file("server-file"))
expect(result.parts.map((part) => part.id)).toEqual(["client-text", "server-file"])
expect(result.replaced).toBe("client-file")
})
})
describe("sameParts", () => {
it("accepts equal hydrated and snapshot parts", () => {
expect(sameParts([text("p1", "done", { end: 2 })], [text("p1", "done", { end: 2 })])).toBe(true)
@@ -66,15 +66,24 @@ describe("session variants", () => {
it("persists an explicit default selection", () => {
const state = setup()
state.selections["agent/code/anthropic/claude-sonnet-4"] = "high"
state.variants.select(undefined)
expect(state.selections).toEqual({ "agent/code/anthropic/claude-sonnet-4": "" })
expect(state.variants.current()).toBeUndefined()
expect(state.messages).toEqual([{ type: "persistVariant", key: "agent/code/anthropic/claude-sonnet-4", value: "" }])
})
it("carries the model default across model changes", () => {
const state = setup()
state.variants.carry(model, undefined, "code")
expect(state.selections).toEqual({ "agent/code/anthropic/claude-sonnet-4": "" })
it("does not shadow a cached variant when carrying the model default", () => {
const global = setup()
global.selections["agent/code/anthropic/claude-sonnet-4"] = "high"
global.variants.carry(model, undefined, "code")
expect(global.selections).toEqual({ "agent/code/anthropic/claude-sonnet-4": "high" })
expect(global.messages).toEqual([])
const session = setup("session-a")
session.selections["agent/code/anthropic/claude-sonnet-4"] = "high"
session.variants.carry(model, undefined, "code", "session-a")
expect(session.selections).toEqual({ "agent/code/anthropic/claude-sonnet-4": "high" })
expect(session.variants.current()).toBe("high")
})
})
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test"
import { createRoot } from "solid-js"
import { createRoot, createSignal } from "solid-js"
import { useFileMention } from "../../webview-ui/src/hooks/useFileMention"
import { FILE_PICKER_RESULT } from "../../webview-ui/src/hooks/file-mention-utils"
import type { ExtensionMessage, WebviewMessage } from "../../webview-ui/src/types/messages"
@@ -750,4 +750,333 @@ describe("useFileMention", () => {
dispose.fn?.()
})
it("renders cached files instantly when opening @ with empty query", () => {
const posted: WebviewMessage[] = []
const handlers = new Set<(message: ExtensionMessage) => void>()
const ctx = {
postMessage: (message: WebviewMessage) => posted.push(message),
onMessage: (handler: (message: ExtensionMessage) => void) => {
handlers.add(handler)
return () => handlers.delete(handler)
},
}
const dispose: { fn?: () => void } = {}
const mention = createRoot((root) => {
dispose.fn = root
return useFileMention(ctx, undefined, () => false)
})
mention.onInput("@", 1)
const refresh = posted.at(-1)
expect(refresh?.type).toBe("requestFileSearch")
for (const handler of handlers) {
handler({
type: "fileSearchResult",
requestId: refresh?.type === "requestFileSearch" ? refresh.requestId : "",
dir: "/repo",
paths: ["src/index.ts", "package.json"],
items: [
{ path: "src/index.ts", type: "opened-file" },
{ path: "package.json", type: "file" },
],
})
}
expect(mention.mentionResults()).toEqual([
{ type: "terminal", value: "terminal", label: "Terminal", description: "Active terminal output" },
{ type: "past-chats", value: "past-chats", label: "Past chats", description: "Search previous sessions" },
{ type: "opened-file", value: "src/index.ts" },
{ type: "file", value: "package.json" },
FILE_PICKER_RESULT,
])
// Close mention and reopen @ - should still be instant
mention.closeMention()
expect(mention.mentionResults().length).toBe(0)
mention.onInput("@", 1)
expect(mention.mentionResults()).toEqual([
{ type: "terminal", value: "terminal", label: "Terminal", description: "Active terminal output" },
{ type: "past-chats", value: "past-chats", label: "Past chats", description: "Search previous sessions" },
{ type: "opened-file", value: "src/index.ts" },
{ type: "file", value: "package.json" },
FILE_PICKER_RESULT,
])
dispose.fn?.()
})
it("replaces deleted cached files when an empty refresh completes", () => {
const posted: WebviewMessage[] = []
const handlers = new Set<(message: ExtensionMessage) => void>()
const ctx = {
postMessage: (message: WebviewMessage) => posted.push(message),
onMessage: (handler: (message: ExtensionMessage) => void) => {
handlers.add(handler)
return () => handlers.delete(handler)
},
}
const dispose: { fn?: () => void } = {}
const mention = createRoot((root) => {
dispose.fn = root
return useFileMention(ctx, undefined, () => false)
})
mention.onInput("@", 1)
const prewarm = posted.at(-1)
expect(prewarm?.type).toBe("requestFileSearch")
for (const handler of handlers) {
handler({
type: "fileSearchResult",
requestId: prewarm?.type === "requestFileSearch" ? prewarm.requestId : "",
dir: "/repo",
paths: ["deleted.ts"],
items: [{ path: "deleted.ts", type: "file" }],
})
}
expect(mention.mentionResults()).toContainEqual({ type: "file", value: "deleted.ts" })
mention.closeMention()
mention.onInput("@", 1)
expect(mention.mentionResults()).toContainEqual({ type: "file", value: "deleted.ts" })
const refresh = posted.at(-1)
expect(refresh?.type).toBe("requestFileSearch")
for (const handler of handlers) {
handler({
type: "fileSearchResult",
requestId: refresh?.type === "requestFileSearch" ? refresh.requestId : "",
dir: "/repo",
paths: [],
items: [],
})
}
expect(mention.mentionResults()).not.toContainEqual({ type: "file", value: "deleted.ts" })
mention.closeMention()
mention.onInput("@", 1)
expect(mention.mentionResults()).not.toContainEqual({ type: "file", value: "deleted.ts" })
dispose.fn?.()
})
it("does not reuse cached files after switching sessions", () => {
const posted: WebviewMessage[] = []
const handlers = new Set<(message: ExtensionMessage) => void>()
const ctx = {
postMessage: (message: WebviewMessage) => posted.push(message),
onMessage: (handler: (message: ExtensionMessage) => void) => {
handlers.add(handler)
return () => handlers.delete(handler)
},
}
const dispose: { fn?: () => void } = {}
const state = createRoot((root) => {
dispose.fn = root
const [session, setSession] = createSignal("session-a")
return { mention: useFileMention(ctx, session, () => false), setSession }
})
state.mention.onInput("@", 1)
const first = posted.at(-1)
expect(first).toMatchObject({ type: "requestFileSearch", sessionID: "session-a" })
for (const handler of handlers) {
handler({
type: "fileSearchResult",
requestId: first?.type === "requestFileSearch" ? first.requestId : "",
dir: "/repo-a",
paths: ["only-a.ts"],
items: [{ path: "only-a.ts", type: "file" }],
})
}
state.mention.closeMention()
state.setSession("session-b")
state.mention.onInput("@", 1)
expect(state.mention.mentionResults()).not.toContainEqual({ type: "file", value: "only-a.ts" })
expect(posted.at(-1)).toMatchObject({ type: "requestFileSearch", sessionID: "session-b", query: "" })
dispose.fn?.()
})
it("bounds remembered session directories", () => {
const posted: WebviewMessage[] = []
const handlers = new Set<(message: ExtensionMessage) => void>()
const ctx = {
postMessage: (message: WebviewMessage) => posted.push(message),
onMessage: (handler: (message: ExtensionMessage) => void) => {
handlers.add(handler)
return () => handlers.delete(handler)
},
}
const dispose: { fn?: () => void } = {}
const state = createRoot((root) => {
dispose.fn = root
const [session, setSession] = createSignal("session-0")
return { mention: useFileMention(ctx, session, () => false), setSession }
})
const reply = (request: WebviewMessage | undefined, dir: string) => {
for (const handler of handlers) {
handler({
type: "fileSearchResult",
requestId: request?.type === "requestFileSearch" ? request.requestId : "",
dir,
paths: [],
items: [],
})
}
}
state.mention.onInput("@", 1)
reply(posted.at(-1), "/repo/0")
for (let index = 1; index <= 8; index++) {
state.mention.closeMention()
state.setSession(`session-${index}`)
state.mention.onInput("@", 1)
reply(posted.at(-1), `/repo/${index}`)
}
state.mention.closeMention()
state.setSession("session-0")
state.mention.onInput("@", 1)
expect(state.mention.mentionResults()).toEqual([
{ type: "terminal", value: "terminal", label: "Terminal", description: "Active terminal output" },
{ type: "past-chats", value: "past-chats", label: "Past chats", description: "Search previous sessions" },
FILE_PICKER_RESULT,
])
dispose.fn?.()
})
it("preserves the highlighted file when fresh results replace cached results", () => {
const posted: WebviewMessage[] = []
const handlers = new Set<(message: ExtensionMessage) => void>()
const ctx = {
postMessage: (message: WebviewMessage) => posted.push(message),
onMessage: (handler: (message: ExtensionMessage) => void) => {
handlers.add(handler)
return () => handlers.delete(handler)
},
}
const dispose: { fn?: () => void } = {}
const mention = createRoot((root) => {
dispose.fn = root
return useFileMention(ctx, undefined, () => false)
})
mention.onInput("@", 1)
const prewarm = posted.at(-1)
for (const handler of handlers) {
handler({
type: "fileSearchResult",
requestId: prewarm?.type === "requestFileSearch" ? prewarm.requestId : "",
dir: "/repo",
paths: ["a.ts", "b.ts"],
items: [
{ path: "a.ts", type: "file" },
{ path: "b.ts", type: "file" },
],
})
}
mention.closeMention()
mention.onInput("@", 1)
const selected = mention.mentionResults().findIndex((item) => item.type === "file" && item.value === "b.ts")
mention.setMentionIndex(selected)
const refresh = posted.at(-1)
for (const handler of handlers) {
handler({
type: "fileSearchResult",
requestId: refresh?.type === "requestFileSearch" ? refresh.requestId : "",
dir: "/repo",
paths: ["new.ts", "b.ts"],
items: [
{ path: "new.ts", type: "file" },
{ path: "b.ts", type: "file" },
],
})
}
expect(mention.mentionResults()[mention.mentionIndex()]).toEqual({ type: "file", value: "b.ts" })
dispose.fn?.()
})
it("ignores a response after the query changes", async () => {
const posted: WebviewMessage[] = []
const handlers = new Set<(message: ExtensionMessage) => void>()
const ctx = {
postMessage: (message: WebviewMessage) => posted.push(message),
onMessage: (handler: (message: ExtensionMessage) => void) => {
handlers.add(handler)
return () => handlers.delete(handler)
},
}
const dispose: { fn?: () => void } = {}
const mention = createRoot((root) => {
dispose.fn = root
return useFileMention(ctx, undefined, () => false)
})
mention.onInput("@old", 4)
await wait(170)
const old = posted.at(-1)
expect(old).toMatchObject({ type: "requestFileSearch", query: "old" })
mention.onInput("@new", 4)
for (const handler of handlers) {
handler({
type: "fileSearchResult",
requestId: old?.type === "requestFileSearch" ? old.requestId : "",
dir: "/repo",
paths: ["old.ts"],
items: [{ path: "old.ts", type: "file" }],
})
}
expect(mention.mentionResults()).not.toContainEqual({ type: "file", value: "old.ts" })
dispose.fn?.()
})
it("ignores a response after closing the mention menu", () => {
const posted: WebviewMessage[] = []
const handlers = new Set<(message: ExtensionMessage) => void>()
const ctx = {
postMessage: (message: WebviewMessage) => posted.push(message),
onMessage: (handler: (message: ExtensionMessage) => void) => {
handlers.add(handler)
return () => handlers.delete(handler)
},
}
const dispose: { fn?: () => void } = {}
const mention = createRoot((root) => {
dispose.fn = root
return useFileMention(ctx, undefined, () => false)
})
mention.onInput("@", 1)
const request = posted.at(-1)
mention.closeMention()
for (const handler of handlers) {
handler({
type: "fileSearchResult",
requestId: request?.type === "requestFileSearch" ? request.requestId : "",
dir: "/repo",
paths: ["late.ts"],
items: [{ path: "late.ts", type: "file" }],
})
}
mention.onInput("@", 1)
expect(mention.mentionResults()).not.toContainEqual({ type: "file", value: "late.ts" })
dispose.fn?.()
})
})
@@ -1011,7 +1011,6 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
const hovered = () => isSelected(row.key)
const preActive = () => isPreActive(row.key)
const starred = () => favoriteKeys().has(modelKey(model.providerID, model.id))
const showProvider = () => row.kind === "favorite" || hasSearch()
const showSelect = () => expanded() && preActive() && !isActive(model)
const starLabel = () =>
`${starred() ? language.t("model.favorite.remove") : language.t("model.favorite.add")}: ${sanitizeName(model.name)}`
@@ -1082,9 +1081,7 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
</Show>
</span>
</Show>
<Show when={showProvider()}>
<span class="model-selector-item-provider-tag">{model.providerName}</span>
</Show>
<span class="model-selector-item-provider-tag">{model.providerName}</span>
</div>
</div>
<Show when={session && props.favorites !== false}>
@@ -31,7 +31,17 @@ export function resolvePromptAgent(input: {
selections: Record<string, string>
pending: string | null
}) {
if (input.sessionID) return input.selections[input.sessionID]
if (input.sessionID) {
const sel = input.selections[input.sessionID]
if (sel) return sel
// Only fall back to the pending selection for draft scopes, not real server
// sessions. A server session with no stored selection must keep its own agent
// rather than be flipped to the stale/default pending agent.
if (!input.sessionID.startsWith("ses_")) {
return input.pending ?? undefined
}
return undefined
}
return input.pending ?? undefined
}
@@ -25,6 +25,15 @@ export function sameParts(local: Part[] = [], snapshot: Part[] = []): boolean {
return true
}
export function mergeOptimisticPart(current: Part[], ids: ReadonlySet<string>, part: Part) {
const index = current.findIndex((item) => ids.has(item.id) && item.type === part.type)
if (index < 0) return { parts: [...current, part] }
const old = current[index]!
const next = current.slice()
next[index] = part
return { parts: next, replaced: old.id }
}
/**
* Reconcile snapshots may be older than in-flight streaming deltas. Preserve
* only appended streamed tail parts and open prefix extensions while still
@@ -53,7 +53,10 @@ export function createSessionVariants(options: Options) {
const carry = (selection: ModelSelection, value: string | undefined, name: string, sessionID?: string) => {
const list = Object.keys(options.find(selection)?.variants ?? {})
if (list.length === 0) return
const next = value === undefined ? DEFAULT_VARIANT : preserveVariant(value, list)
// An absent value means the model default, not an explicit user choice.
// Do not write a default sentinel here because it would shadow a cached
// agent-level variant when this selection is resolved for a new session.
const next = preserveVariant(value, list)
if (next === undefined) return
const key = variantKey(selection, name, sessionID)
options.set(key, next)
@@ -77,7 +77,7 @@ import { getAgentModel } from "./session-model-store"
import { resolveMessagePrefs } from "./session-preferences"
import { errorIDs, preserveSessionErrors, withoutResolvedSessionErrors } from "./session-errors"
import { PartStash } from "./part-stash"
import { mergeParts } from "./session-parts"
import { mergeOptimisticPart, mergeParts } from "./session-parts"
import { mergeMessages, sameReconcileShape } from "./session-merge"
import { state as todoState } from "./todo-revert"
import { sessionVariantKeys, transferVariants, variantKey } from "./session-variant-store"
@@ -459,6 +459,9 @@ export const SessionProvider: ParentComponent = (props) => {
// Tracks optimistic messageIDs that haven't been confirmed by the server yet.
// Prevents handleMessagesLoaded from wiping them when it replaces the array.
const pendingOptimistic = new Map<string, Set<string>>()
// Keeps optimistic parts visible between message.updated and their canonical
// message.part.updated events.
const optimisticParts = new Map<string, Set<string>>()
// Sessions can be created/imported while an older list request is still in flight.
// Keep them until a later list payload confirms them or deletion arrives.
const freshSessions = new Set<string>()
@@ -1480,13 +1483,14 @@ export const SessionProvider: ParentComponent = (props) => {
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]!
const parts = msg.parts ?? []
if (mode === "reconcile" && store.parts[msg.id]) {
if (mode === "reconcile" && store.parts[msg.id] && !optimisticParts.has(msg.id)) {
const merged = mergeParts(store.parts[msg.id], parts, input.since ?? Number.POSITIVE_INFINITY)
setStore("parts", msg.id, reconcile(merged, { key: "id" }))
stash.remove(msg.id)
continue
}
if (parts.length > 0) {
optimisticParts.delete(msg.id)
loadedParts[msg.id] = parts
if (i >= cutoff) {
setStore("parts", msg.id, parts)
@@ -1539,22 +1543,12 @@ export const SessionProvider: ParentComponent = (props) => {
function handleMessageCreated(message: Message) {
if (message.role === "assistant") clearSessionDraftDiscarded(message.sessionID)
// Message confirmed by server — no longer optimistic.
// Clear placeholder parts so they don't duplicate alongside real parts
// arriving via individual part.updated events (the server's message.updated
// SSE event does NOT include parts).
// Keep placeholder parts until their canonical part.updated events arrive.
// The message.updated SSE event does not include parts, so clearing them
// here makes a queued prompt render only its status during that gap.
const pending = pendingOptimistic.get(message.sessionID)
const wasOptimistic = pending?.has(message.id)
pending?.delete(message.id)
if (wasOptimistic) {
setStore(
"parts",
produce((p) => {
delete p[message.id]
}),
)
}
const exists = (store.messages[message.sessionID] ?? []).some((msg) => msg.id === message.id)
setStore("messages", message.sessionID, (msgs = []) => {
if (message.sessionErrorID && msgs.some((msg) => msg.sessionErrorID === message.sessionErrorID)) return msgs
@@ -1575,6 +1569,7 @@ export const SessionProvider: ParentComponent = (props) => {
recoverPrefs(message.sessionID, [message])
if (message.parts && message.parts.length > 0) {
optimisticParts.delete(message.id)
stash.remove(message.id)
setStore("parts", message.id, message.parts)
}
@@ -1610,6 +1605,19 @@ export const SessionProvider: ParentComponent = (props) => {
setStore("parts", effectiveMessageID, stashed)
}
const current = store.parts[effectiveMessageID] ?? []
const index = current.findIndex((item) => item.id === part.id)
const pending = optimisticParts.get(effectiveMessageID)
if (index < 0 && pending) {
const merged = mergeOptimisticPart(current, pending, part)
setStore("parts", effectiveMessageID, merged.parts)
if (merged.replaced) {
pending.delete(merged.replaced)
if (pending.size === 0) optimisticParts.delete(effectiveMessageID)
}
return
}
setStore(
"parts",
produce((parts) => {
@@ -1691,6 +1699,7 @@ export const SessionProvider: ParentComponent = (props) => {
delete map[sessionID]
}),
)
for (const msg of store.messages[sessionID] ?? []) optimisticParts.delete(msg.id)
// Session is idle - any remaining pending optimistic IDs are either
// already confirmed (messageCreated removed them) or orphaned (queued
// callbacks were dropped on abort). Clean up the tracking set; the
@@ -1819,6 +1828,7 @@ export const SessionProvider: ParentComponent = (props) => {
if (!message.messageID && sid) aborts.clear(sid)
if (sid && message.messageID) {
pendingOptimistic.get(sid)?.delete(message.messageID)
optimisticParts.delete(message.messageID)
stash.remove(message.messageID)
batch(() => {
setStore("messages", sid, (msgs = []) => msgs.filter((m) => m.id !== message.messageID))
@@ -1980,6 +1990,7 @@ export const SessionProvider: ParentComponent = (props) => {
// Collect message IDs so we can clean up their parts (store + stash)
const msgs = store.messages[sessionID] ?? []
const msgIds = msgs.map((m) => m.id)
for (const id of msgIds) optimisticParts.delete(id)
for (const id of msgIds) stash.remove(id)
clearHiddenErrors(msgIds)
@@ -2043,6 +2054,7 @@ export const SessionProvider: ParentComponent = (props) => {
// Splices the message from the store and deletes its parts.
function handleMessageRemoved(sessionID: string, messageID: string) {
optimisticParts.delete(messageID)
setStore("messages", sessionID, (msgs = []) => msgs.filter((m) => m.id !== messageID))
dropMessageTools(sessionID, messageID)
clearHiddenErrors([messageID])
@@ -2222,6 +2234,7 @@ export const SessionProvider: ParentComponent = (props) => {
setStore("messages", sid, (msgs = []) => [...msgs, temp])
setStore("parts", messageID, parts)
if (parts.length > 0) optimisticParts.set(messageID, new Set(parts.map((part) => part.id)))
patchPage(sid, { lastMutation: "append" })
queueMicrotask(() => window.dispatchEvent(new CustomEvent("resumeAutoScroll")))
}
@@ -1,6 +1,12 @@
import { createEffect, createSignal, onCleanup } from "solid-js"
import type { Accessor } from "solid-js"
import type { FileAttachment, SessionSearchItem, WebviewMessage, ExtensionMessage } from "../types/messages"
import type {
FileAttachment,
FileSearchItem,
SessionSearchItem,
WebviewMessage,
ExtensionMessage,
} from "../types/messages"
import {
AT_PATTERN,
syncMentionedPaths as _syncMentionedPaths,
@@ -19,6 +25,21 @@ import {
} from "./file-mention-utils"
const FILE_SEARCH_DEBOUNCE_MS = 150
const FILE_SEARCH_CACHE_MS = 5000
const FILE_SEARCH_CACHE_LIMIT = 8
type FileSearchCache = {
items: Array<FileSearchItem | string>
updated: number
revision: number
}
type FileSearchRequest = {
id: string
query: string
scope: string
revision: number
}
interface VSCodeContext {
postMessage: (message: WebviewMessage) => void
@@ -112,6 +133,8 @@ export function useFileMention(
const [sessionPicker, setSessionPicker] = createSignal(false)
const [sessionCandidates, setSessionCandidates] = createSignal<SessionSearchItem[]>([])
let workspaceDir = ""
const cache = new Map<string, FileSearchCache>()
const dirs = new Map<string, string>()
// Accumulates every path ever mentioned so syncMentionedPaths can
// rediscover them after a native undo restores the text.
const knownPaths = new Set<string>()
@@ -121,6 +144,9 @@ export function useFileMention(
let fileSearchTimer: ReturnType<typeof setTimeout> | undefined
let fileSearchCounter = 0
let fileSearchRevision = 0
let fileSearchRequest: FileSearchRequest | undefined
let prewarmRequest: FileSearchRequest | undefined
let filePickerCounter = 0
let sessionSearchCounter = 0
let pickerState: {
@@ -134,11 +160,89 @@ export function useFileMention(
let pendingArrowSnap: { timer: ReturnType<typeof setTimeout>; prevValue: string; prevPosition: number } | undefined
const showMention = () => mentionQuery() !== null
const scope = () => sessionID?.() ?? ""
let activeScope = scope()
const syncScope = () => {
const value = scope()
if (value === activeScope) return value
activeScope = value
if (fileSearchTimer) clearTimeout(fileSearchTimer)
fileSearchRevision++
fileSearchRequest = undefined
prewarmRequest = undefined
workspaceDir = dirs.get(value) ?? ""
setMentionResults([])
setMentionIndex(0)
return value
}
const readCache = (dir: string): Array<FileSearchItem | string> => {
if (!dir) return []
const entry = cache.get(dir)
if (!entry) return []
if (Date.now() - entry.updated <= FILE_SEARCH_CACHE_MS) return entry.items
cache.delete(dir)
return []
}
const writeCache = (dir: string, items: Array<FileSearchItem | string>, revision: number) => {
if (!dir) return
const entry = cache.get(dir)
if (entry && entry.revision > revision) return
cache.delete(dir)
cache.set(dir, { items, updated: Date.now(), revision })
while (cache.size > FILE_SEARCH_CACHE_LIMIT) {
const oldest = cache.keys().next().value
if (!oldest) return
cache.delete(oldest)
}
}
const writeDir = (id: string, dir: string) => {
dirs.delete(id)
dirs.set(id, dir)
while (dirs.size > FILE_SEARCH_CACHE_LIMIT) {
const oldest = dirs.keys().next().value
if (oldest === undefined) return
dirs.delete(oldest)
}
}
const replaceResults = (items: MentionResult[]) => {
const index = mentionIndex()
const selected = mentionResults()[index]
setMentionResults(items)
if (!selected) {
setMentionIndex(0)
return
}
const next = items.findIndex((item) => item.type === selected.type && item.value === selected.value)
setMentionIndex(next >= 0 ? next : Math.min(index, Math.max(items.length - 1, 0)))
}
createEffect(() => {
if (!showMention()) setMentionIndex(0)
})
createEffect(() => {
const id = syncScope()
if (fileSearchTimer) clearTimeout(fileSearchTimer)
fileSearchRequest = undefined
setMentionQuery(null)
setMentionResults([])
setMentionIndex(0)
const revision = ++fileSearchRevision
const requestId = `file-search-prewarm-${revision}`
prewarmRequest = { id: requestId, query: "", scope: id, revision }
vscode.postMessage({
type: "requestFileSearch",
query: "",
requestId,
...(id ? { sessionID: id } : {}),
})
})
const unsubscribe = vscode.onMessage((message) => {
if (message.type === "sessionSearchResult") {
if (message.requestId !== `session-search-${sessionSearchCounter}`) return
@@ -152,12 +256,25 @@ export function useFileMention(
return
}
if (message.type !== "fileSearchResult") return
if (message.requestId === `file-search-${fileSearchCounter}`) {
const items = message.items ?? message.paths.map((path) => ({ path, type: "file" as const }))
const request =
message.requestId === fileSearchRequest?.id
? fileSearchRequest
: message.requestId === prewarmRequest?.id
? prewarmRequest
: undefined
if (!request || request.scope !== scope()) return
if (request === fileSearchRequest) fileSearchRequest = undefined
if (request === prewarmRequest) prewarmRequest = undefined
if (request.revision < fileSearchRevision) return
const items = message.items ?? message.paths.map((path) => ({ path, type: "file" as const }))
if (message.dir) {
writeDir(request.scope, message.dir)
workspaceDir = message.dir
setMentionResults(buildMentionResults(mentionQuery() ?? "", items, git?.() ?? true))
setMentionIndex(0)
}
if (!request.query) writeCache(message.dir, items, request.revision)
if (!showMention() || request.query !== mentionQuery()) return
replaceResults(buildMentionResults(request.query, items, git?.() ?? true))
})
onCleanup(() => {
@@ -168,19 +285,33 @@ export function useFileMention(
const requestFileSearch = (query: string) => {
if (fileSearchTimer) clearTimeout(fileSearchTimer)
fileSearchTimer = setTimeout(() => {
fileSearchCounter++
const id = sessionID?.()
const revision = ++fileSearchRevision
const request = {
id: `file-search-${++fileSearchCounter}`,
query,
scope: syncScope(),
revision,
}
fileSearchRequest = request
const send = () => {
vscode.postMessage({
type: "requestFileSearch",
query,
requestId: `file-search-${fileSearchCounter}`,
...(id ? { sessionID: id } : {}),
requestId: request.id,
...(request.scope ? { sessionID: request.scope } : {}),
})
}, FILE_SEARCH_DEBOUNCE_MS)
}
if (!query) {
send()
return
}
fileSearchTimer = setTimeout(send, FILE_SEARCH_DEBOUNCE_MS)
}
const closeMention = () => {
if (fileSearchTimer) clearTimeout(fileSearchTimer)
fileSearchRevision++
fileSearchRequest = undefined
setMentionQuery(null)
setMentionResults([])
setSessionPicker(false)
@@ -290,6 +421,7 @@ export function useFileMention(
let suppress = false
const onInput = (val: string, cursor: number) => {
syncScope()
syncMentionedPaths(val)
if (suppress) return
closeSessionPicker()
@@ -298,8 +430,16 @@ export function useFileMention(
if (match) {
const query = match[1] ?? ""
setMentionQuery(query)
const items = readCache(workspaceDir)
if (!query) {
setMentionResults(buildMentionResults("", items, git?.() ?? true))
setMentionIndex(0)
requestFileSearch("")
return
}
setMentionResults((prev) => {
const next = filterMentionResults(query, prev)
const base = prev.length ? prev : buildMentionResults("", items, git?.() ?? true)
const next = filterMentionResults(query, base)
if (next.length) return next
return buildMentionResults(query, [], git?.() ?? true)
})
+79 -56
View File
@@ -2,9 +2,9 @@ import { Telemetry } from "@kilocode/kilo-telemetry"
import { Agent } from "@/agent/agent"
import { TuiEvent } from "@/server/tui-event"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { Identifier } from "@/id/id"
import { Instance } from "@/kilocode/instance"
import { KilocodeModelState } from "@/kilocode/config/model-state"
import { Provider } from "@/provider/provider"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ModelV2 } from "@opencode-ai/core/model"
@@ -17,17 +17,14 @@ import { MessageV2 } from "@/session/message-v2"
import { SessionStatus } from "@/session/status"
import { Todo } from "@/session/todo"
import { makeRuntime } from "@/effect/run-service"
import { Effect, Schema } from "effect"
import { Effect } from "effect"
import * as Log from "@opencode-ai/core/util/log"
import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue"
import { lazy } from "@/util/lazy"
import path from "path"
import z from "zod"
import { PlanFile } from "@/kilocode/plan-file"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // kilocode_change
const agents = lazy(() => makeRuntime(Agent.Service, AppNodeBuilder.build(Agent.node)))
const providers = lazy(() => makeRuntime(Provider.Service, AppNodeBuilder.build(Provider.node)))
const todo = lazy(() => makeRuntime(Todo.Service, Todo.defaultLayer))
const llm = lazy(() => makeRuntime(LLM.Service, AppNodeBuilder.build(LLM.node)))
const pending = new Map<SessionID, AbortController>()
@@ -36,8 +33,15 @@ export const PlanFollowupRuntime = {
agent(name: string): Promise<Agent.Info | undefined> {
return agents().runPromise((svc) => svc.get(name))
},
model(providerID: ProviderV2.ID, modelID: ModelV2.ID): Promise<Provider.Model> {
return providers().runPromise((svc) => svc.getModel(providerID, modelID))
async modelIfAvailable(providerID: ProviderV2.ID, modelID: ModelV2.ID): Promise<Provider.Model | undefined> {
const { AppRuntime } = await import("@/effect/app-runtime")
return AppRuntime.runPromise(
Provider.Service.use((svc) =>
svc.getModel(providerID, modelID).pipe(
Effect.catchIf(Provider.ModelNotFoundError.isInstance, () => Effect.succeed(undefined)),
),
),
)
},
todo: {
get(sessionID: SessionID) {
@@ -108,9 +112,15 @@ export async function generateHandover(input: {
const log = Log.create({ service: "plan.followup" })
try {
const entry = await PlanFollowupRuntime.agent("compaction")
const model = entry?.model
? await PlanFollowupRuntime.model(entry.model.providerID, entry.model.modelID)
: await PlanFollowupRuntime.model(input.model.providerID, input.model.modelID)
const lookup = async (providerID: ProviderV2.ID, modelID: ModelV2.ID) =>
PlanFollowupRuntime.modelIfAvailable(providerID, modelID).catch((err) => {
log.warn("handover model lookup failed", { providerID, modelID, err })
return undefined
})
const model =
(entry?.model && (await lookup(entry.model.providerID, entry.model.modelID))) ||
(await lookup(input.model.providerID, input.model.modelID))
if (!model) return ""
const sessionID = SessionID.make(Identifier.ascending("session"))
const userMsg: MessageV2.User = {
@@ -172,54 +182,45 @@ export namespace PlanFollowup {
function resolveVariant(value: string | undefined, model: Provider.Model | undefined) {
if (!value) return undefined
if (!model?.variants?.[value]) return undefined
if (model && !model.variants?.[value]) return undefined
return value
}
const ModelState = z
.object({
model: z
.record(
z.string(),
z.object({
providerID: z.custom<ProviderV2.ID>(Schema.is(ProviderV2.ID)),
modelID: z.custom<ModelV2.ID>(Schema.is(ModelV2.ID)),
}),
)
.optional(),
variant: z.record(z.string(), z.string().optional()).optional(),
})
.passthrough()
async function stamp(ref: { providerID: string; modelID: string }, variant?: string) {
const model = {
providerID: ProviderV2.ID.make(ref.providerID),
modelID: ModelV2.ID.make(ref.modelID),
}
try {
const full = await PlanFollowupRuntime.modelIfAvailable(model.providerID, model.modelID)
if (!full) return
return { ...model, variant: resolveVariant(variant, full) }
} catch (err) {
log.warn("code model catalog lookup failed", {
providerID: model.providerID,
modelID: model.modelID,
err,
})
return { ...model, variant: resolveVariant(variant, undefined) }
}
}
async function pick(
ref: { providerID: string; modelID: string } | undefined,
variant?: string,
) {
if (!ref) return
return stamp(ref, variant)
}
async function resolveCodeModel(input: Pick<MessageV2.User, "model">) {
const state =
Flag.KILO_CLIENT === "cli"
? await Bun.file(path.join(Global.Path.state, "model.json"))
.text()
.then((raw) => ModelState.safeParse(JSON.parse(raw)))
.then((r) => (r.success ? r.data : undefined))
.catch(() => undefined)
: undefined
const saved = state?.model?.code
if (saved) {
const full = await PlanFollowupRuntime.model(saved.providerID, saved.modelID).catch(() => undefined)
if (full) {
const key = `${saved.providerID}/${saved.modelID}`
return {
model: { ...saved, variant: resolveVariant(state?.variant?.[key], full) },
}
}
}
const state = Flag.KILO_CLIENT === "cli" ? await KilocodeModelState.get().catch(() => undefined) : undefined
const saved = state?.model.code
const entry = await PlanFollowupRuntime.agent("code")
if (entry?.model) {
const full = await PlanFollowupRuntime.model(entry.model.providerID, entry.model.modelID).catch(() => undefined)
if (full) {
return {
model: { ...entry.model, variant: resolveVariant(entry.variant, full) },
}
}
}
const next =
(await pick(saved, saved && state.variant[`${saved.providerID}/${saved.modelID}`])) ??
(await pick(entry?.model, entry?.variant))
if (next) return { model: next }
return input
}
@@ -324,6 +325,7 @@ export namespace PlanFollowup {
labelKey: "plan.followup.answer.newSession",
description: "Implement in a fresh session with a clean context",
descriptionKey: "plan.followup.answer.newSession.description",
mode: "code",
},
{
label: ANSWER_CONTINUE,
@@ -373,20 +375,29 @@ export namespace PlanFollowup {
model: MessageV2.User["model"]
abort?: AbortSignal
}) {
const code = await resolveCodeModel({
model: input.model,
})
const session = await PlanFollowupRuntime.session((svc) => svc.get(input.sessionID))
const { provide } = await import("@/kilocode/instance")
await provide({
directory: session.directory,
fn: async () => {
const code = await resolveCodeModel({
model: input.model,
})
// Create the session FIRST so session.created fires immediately while the
// VS Code extension's pendingFollowup gate (30s TTL) is still fresh. The
// handover generation below can take tens of seconds and must not block
// the SSE event that drives the webview tab switch.
const next = await PlanFollowupRuntime.session((svc) => svc.create({}))
const next = await PlanFollowupRuntime.session((svc) =>
svc.create({
agent: "code",
model: {
id: code.model.modelID,
providerID: code.model.providerID,
variant: code.model.variant ?? "default",
},
}),
)
const ctl = new AbortController()
pending.set(next.id, ctl)
const [{ AppRuntime }, { EventV2Bridge }] = await Promise.all([
@@ -560,6 +571,18 @@ export namespace PlanFollowup {
model: code.model,
text: "Implement the plan above.",
})
await PlanFollowupRuntime.session((svc) =>
svc.setAgentModel({
sessionID: input.sessionID,
agent: "code",
model: {
id: code.model.modelID,
providerID: code.model.providerID,
variant: code.model.variant ?? "default",
},
time: msg.time.created,
}),
)
KiloSessionPromptQueue.retarget(input.sessionID, msg.id)
return "continue"
}
@@ -277,15 +277,14 @@ function mockHandoverDeps(text: string, opts?: { agent?: Agent.Info | null }) {
const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue(
(opts?.agent === null ? undefined : (opts?.agent ?? fakeAgent)) as any,
)
const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel)
const availableSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockResolvedValue(fakeModel)
const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockResolvedValue(text)
return {
agentSpy,
modelSpy,
handoverSpy,
[Symbol.dispose]() {
agentSpy.mockRestore()
modelSpy.mockRestore()
availableSpy.mockRestore()
handoverSpy.mockRestore()
},
}
@@ -364,10 +363,8 @@ describe("plan follow-up", () => {
const refineOpt = q.options.find((o) => o.label === PlanFollowup.ANSWER_KEEP_REFINING)
expect(refineOpt?.mode).toBe("plan")
// Start new session should not carry a mode (it opens a new session — the
// current picker is irrelevant once the session switches).
const newOpt = q.options.find((o) => o.label === PlanFollowup.ANSWER_NEW_SESSION)
expect(newOpt?.mode).toBeUndefined()
expect(newOpt?.mode).toBe("code")
await question.reject(item.id)
await expect(pending).resolves.toBe("break")
@@ -464,11 +461,11 @@ describe("plan follow-up", () => {
}
return undefined as any
})
const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(savedConfigFull)
const availableSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockResolvedValue(savedConfigFull)
using _ = {
[Symbol.dispose]() {
get.mockRestore()
modelSpy.mockRestore()
availableSpy.mockRestore()
},
}
const seeded = await seed({ text: "1. Build\n2. Test" })
@@ -660,7 +657,7 @@ describe("plan follow-up", () => {
},
parts: [],
})
const modelSpy = spyOn(PlanFollowupRuntime, "model").mockImplementation(
const availableSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockImplementation(
async (providerID: string, modelID: string) => {
if (providerID === saved.providerID && modelID === saved.modelID) return savedConfigFull
return fakeModel
@@ -672,7 +669,7 @@ describe("plan follow-up", () => {
using _mocks = {
handoverSpy,
[Symbol.dispose]() {
modelSpy.mockRestore()
availableSpy.mockRestore()
handoverSpy.mockRestore()
},
}
@@ -762,7 +759,7 @@ describe("plan follow-up", () => {
withInstance(async () => {
await using other = await tmpdir({ git: true })
const get = spyOn(PlanFollowupRuntime, "agent").mockImplementation(async () => undefined as any)
const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel)
const availableSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockResolvedValue(fakeModel)
const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockResolvedValue("")
const loop = spyOn(PlanFollowupRuntime, "loop").mockResolvedValue({
info: {
@@ -790,7 +787,7 @@ describe("plan follow-up", () => {
using _mocks = {
[Symbol.dispose]() {
get.mockRestore()
modelSpy.mockRestore()
availableSpy.mockRestore()
handoverSpy.mockRestore()
loop.mockRestore()
},
@@ -870,7 +867,7 @@ describe("plan follow-up", () => {
}
return undefined as any
})
const modelSpy = spyOn(PlanFollowupRuntime, "model").mockImplementation(
const modelSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockImplementation(
async (providerID: string, modelID: string) => {
if (providerID === saved.providerID && modelID === saved.modelID) return savedFull
if (providerID === config.providerID && modelID === config.modelID) return configFull
@@ -908,7 +905,67 @@ describe("plan follow-up", () => {
expect(user.info.model).toEqual({ ...saved, variant: savedVar })
}))
test("ask - falls back to configured code model when saved CLI code model is unavailable", () =>
test("ask - uses saved CLI code model even when catalog lookup fails", () =>
withInstance(async () => {
await writeState({
model: { code: saved },
variant: { [savedKey]: savedVar },
})
const get = spyOn(PlanFollowupRuntime, "agent").mockImplementation(async (name: string) => {
if (name === "code") {
return {
name: "code",
mode: "primary",
permission: [],
options: {},
model: config,
variant: configVar,
} as any
}
return undefined as any
})
const modelSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockImplementation(async () => {
throw new Error("catalog unavailable")
})
using _ = {
[Symbol.dispose]() {
get.mockRestore()
modelSpy.mockRestore()
},
}
const seeded = await seed({ text: "1. Build\n2. Test" })
const pending = PlanFollowup.ask({
question,
sessionID: seeded.sessionID,
messages: seeded.messages,
abort: AbortSignal.any([]),
})
const item = await waitQuestion(seeded.sessionID)
expect(item).toBeDefined()
if (!item) return
await question.reply({
requestID: item.id,
answers: [[PlanFollowup.ANSWER_CONTINUE]],
})
await expect(pending).resolves.toBe("continue")
const user = await latestUser(seeded.sessionID)
expect(user?.info.role).toBe("user")
if (!user || user.info.role !== "user") return
expect(user.info.agent).toBe("code")
expect(user.info.model).toEqual({ ...saved, variant: savedVar })
const current = await store.get(seeded.sessionID)
expect(current.agent).toBe("code")
expect(current.model).toEqual({
id: saved.modelID,
providerID: saved.providerID,
variant: savedVar,
})
}))
test("ask - falls back to configured code model when saved CLI code model is missing", () =>
withInstance(async () => {
await writeState({
model: { code: { providerID: ProviderV2.ID.make("missing"), modelID: ModelV2.ID.make("ghost") } },
@@ -926,9 +983,9 @@ describe("plan follow-up", () => {
}
return undefined as any
})
const modelSpy = spyOn(PlanFollowupRuntime, "model").mockImplementation(
const modelSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockImplementation(
async (providerID: string, modelID: string) => {
if (providerID === "missing" && modelID === "ghost") throw new Error("missing model")
if (providerID === "missing" && modelID === "ghost") return undefined
return configFull
},
)
@@ -963,6 +1020,55 @@ describe("plan follow-up", () => {
expect(user.info.model).toEqual({ ...config, variant: configVar })
}))
test("ask - uses configured code model even when catalog lookup fails", () =>
withInstance(async () => {
const get = spyOn(PlanFollowupRuntime, "agent").mockImplementation(async (name: string) => {
if (name === "code") {
return {
name: "code",
mode: "primary",
permission: [],
options: {},
model: config,
variant: configVar,
} as any
}
return undefined as any
})
const modelSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockImplementation(async () => {
throw new Error("catalog unavailable")
})
using _ = {
[Symbol.dispose]() {
get.mockRestore()
modelSpy.mockRestore()
},
}
const seeded = await seed({ text: "1. Build\n2. Test" })
const pending = PlanFollowup.ask({
question,
sessionID: seeded.sessionID,
messages: seeded.messages,
abort: AbortSignal.any([]),
})
const item = await waitQuestion(seeded.sessionID)
expect(item).toBeDefined()
if (!item) return
await question.reply({
requestID: item.id,
answers: [[PlanFollowup.ANSWER_CONTINUE]],
})
await expect(pending).resolves.toBe("continue")
const user = await latestUser(seeded.sessionID)
expect(user?.info.role).toBe("user")
if (!user || user.info.role !== "user") return
expect(user.info.agent).toBe("code")
expect(user.info.model).toEqual({ ...config, variant: configVar })
}))
test("ask - falls back to planning model when no saved or configured code model exists", () =>
withInstance(async () => {
const get = spyOn(PlanFollowupRuntime, "agent").mockImplementation(async (name: string) => {
@@ -999,6 +1105,90 @@ describe("plan follow-up", () => {
expect(user.info.model).toEqual({ ...model, variant: planVar })
}))
test("ask - new session uses saved code model even when catalog lookup fails", () =>
withInstance(async () => {
await writeState({
model: { code: saved },
variant: { [savedKey]: savedVar },
})
const get = spyOn(PlanFollowupRuntime, "agent").mockImplementation(async (name: string) => {
if (name === "compaction") return fakeAgent as any
return undefined as any
})
const modelSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockImplementation(async () => {
throw new Error("catalog unavailable")
})
const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockResolvedValue("")
const loop = spyOn(PlanFollowupRuntime, "loop").mockResolvedValue({
info: {
id: MessageID.make("msg_test"),
role: "assistant",
sessionID: SessionID.make("ses_test"),
time: { created: Date.now() },
parentID: MessageID.make("msg_parent"),
modelID: ModelV2.ID.make("test"),
providerID: ProviderV2.ID.make("test"),
mode: "code",
agent: "code",
path: { cwd: "/tmp", root: "/tmp" },
cost: 0,
tokens: {
total: 0,
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
},
parts: [],
})
using _ = {
[Symbol.dispose]() {
get.mockRestore()
modelSpy.mockRestore()
handoverSpy.mockRestore()
loop.mockRestore()
},
}
const seeded = await seed({ text: "1. Add API\n2. Add tests" })
const before = await sessions()
const pending = PlanFollowup.ask({
question,
sessionID: seeded.sessionID,
messages: seeded.messages,
abort: AbortSignal.any([]),
})
const item = await waitQuestion(seeded.sessionID)
expect(item).toBeDefined()
if (!item) return
await question.reply({
requestID: item.id,
answers: [[PlanFollowup.ANSWER_NEW_SESSION]],
})
await expect(pending).resolves.toBe("break")
const after = await sessions()
const prev = new Set(before.map((item) => item.id))
const added = after.filter((item) => !prev.has(item.id))
expect(added).toHaveLength(1)
const next = added[0]
if (!next) throw new Error("expected follow-up session")
expect(next.agent).toBe("code")
expect(next.model).toEqual({
id: saved.modelID,
providerID: saved.providerID,
variant: savedVar,
})
const messages = await store.messages({ sessionID: next.id })
const user = messages.find((item) => item.info.role === "user")
if (!user || user.info.role !== "user") throw new Error("expected user message")
expect(user.info.agent).toBe("code")
expect(user.info.model).toEqual({ ...saved, variant: savedVar })
}))
test("ask - new session omits handover section when LLM returns empty", () =>
withInstance(async () => {
const loop = spyOn(PlanFollowupRuntime, "loop").mockResolvedValue({
@@ -1191,7 +1381,7 @@ describe("plan follow-up", () => {
const deferred = Promise.withResolvers<string>()
const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue(fakeAgent as any)
const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel)
const availableSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockResolvedValue(fakeModel)
const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockImplementation(() =>
deferred.promise.then((text) => {
handoverResolvedAt = performance.now()
@@ -1224,7 +1414,7 @@ describe("plan follow-up", () => {
using _ = {
[Symbol.dispose]() {
agentSpy.mockRestore()
modelSpy.mockRestore()
availableSpy.mockRestore()
handoverSpy.mockRestore()
loop.mockRestore()
unsub()
@@ -1279,7 +1469,7 @@ describe("plan follow-up", () => {
const deferred = Promise.withResolvers<string>()
const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue(fakeAgent as any)
const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel)
const availableSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockResolvedValue(fakeModel)
const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockImplementation(() => deferred.promise)
const loop = spyOn(PlanFollowupRuntime, "loop").mockResolvedValue({
info: {
@@ -1301,7 +1491,7 @@ describe("plan follow-up", () => {
using _ = {
[Symbol.dispose]() {
agentSpy.mockRestore()
modelSpy.mockRestore()
availableSpy.mockRestore()
handoverSpy.mockRestore()
loop.mockRestore()
unsub()
@@ -1380,7 +1570,7 @@ describe("plan follow-up", () => {
const deferred = Promise.withResolvers<string>()
const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue(fakeAgent as any)
const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel)
const availableSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockResolvedValue(fakeModel)
const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockImplementation(() => deferred.promise)
const loop = spyOn(PlanFollowupRuntime, "loop").mockResolvedValue({
info: {
@@ -1402,7 +1592,7 @@ describe("plan follow-up", () => {
using _ = {
[Symbol.dispose]() {
agentSpy.mockRestore()
modelSpy.mockRestore()
availableSpy.mockRestore()
handoverSpy.mockRestore()
loop.mockRestore()
created()
@@ -1539,12 +1729,12 @@ describe("plan follow-up", () => {
test("generateHandover - returns empty string on LLM stream failure", () =>
withInstance(async () => {
const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue(fakeAgent)
const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel)
const availableSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockResolvedValue(fakeModel)
const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockRejectedValue(new Error("provider unavailable"))
using _ = {
[Symbol.dispose]() {
agentSpy.mockRestore()
modelSpy.mockRestore()
availableSpy.mockRestore()
handoverSpy.mockRestore()
},
}
@@ -1556,12 +1746,12 @@ describe("plan follow-up", () => {
test("generateHandover - returns empty string on text stream rejection", () =>
withInstance(async () => {
const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue(fakeAgent)
const modelSpy = spyOn(PlanFollowupRuntime, "model").mockResolvedValue(fakeModel)
const availableSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockResolvedValue(fakeModel)
const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockRejectedValue(new Error("stream aborted"))
using _ = {
[Symbol.dispose]() {
agentSpy.mockRestore()
modelSpy.mockRestore()
availableSpy.mockRestore()
handoverSpy.mockRestore()
},
}
@@ -1588,4 +1778,52 @@ describe("plan follow-up", () => {
expect(result).toBe("## Discoveries\n\nKey finding here")
expect(mocks.handoverSpy).toHaveBeenCalledTimes(1)
}))
test("generateHandover - returns empty string when model lookup fails", () =>
withInstance(async () => {
const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue({
...fakeAgent,
model,
} as any)
const availableSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockRejectedValue(new Error("catalog unavailable"))
const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockResolvedValue("should not run")
using _ = {
[Symbol.dispose]() {
agentSpy.mockRestore()
availableSpy.mockRestore()
handoverSpy.mockRestore()
},
}
const seeded = await seed({ text: "1. Build\n2. Test" })
const result = await generateHandover({ messages: seeded.messages, model })
expect(result).toBe("")
expect(handoverSpy).not.toHaveBeenCalled()
}))
test("generateHandover - uses the plan model when the compaction model is missing", () =>
withInstance(async () => {
const agentSpy = spyOn(PlanFollowupRuntime, "agent").mockResolvedValue({
...fakeAgent,
model: saved,
} as any)
const availableSpy = spyOn(PlanFollowupRuntime, "modelIfAvailable").mockImplementation(
async (providerID: string, modelID: string) => {
if (providerID === saved.providerID && modelID === saved.modelID) return undefined
if (providerID === model.providerID && modelID === model.modelID) return fakeModel
return undefined
},
)
const handoverSpy = spyOn(PlanFollowupRuntime, "handover").mockResolvedValue("## Discoveries\n\nFrom plan model")
using _ = {
[Symbol.dispose]() {
agentSpy.mockRestore()
availableSpy.mockRestore()
handoverSpy.mockRestore()
},
}
const seeded = await seed({ text: "1. Build\n2. Test" })
const result = await generateHandover({ messages: seeded.messages, model })
expect(result).toBe("## Discoveries\n\nFrom plan model")
expect(handoverSpy).toHaveBeenCalledTimes(1)
}))
})