mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-31 01:37:28 +08:00
Merge branch 'main' into acidic-fennel
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep file mention suggestions current and scoped to the active workspace while preserving instant cached results.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Switch to the code model when starting implementation after a planning session.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show the model provider next to every model in the model selector.
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user