mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-24 16:02:55 +08:00
Merge remote-tracking branch 'origin/main' into valley-hawk
This commit is contained in:
@@ -74,7 +74,7 @@ function mergeConfigConcatArrays(target: Info, source: Info): Info {
|
||||
|
||||
function normalizeLoadedConfig(data: unknown, source: string) {
|
||||
if (!isRecord(data)) return data
|
||||
const copy = { ...data }
|
||||
const copy = KilocodeConfig.retireIndexingFlag({ ...data }, source) // kilocode_change
|
||||
const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
|
||||
if (!hadLegacy) return copy
|
||||
delete copy.theme
|
||||
@@ -350,9 +350,6 @@ export const Info = Schema.Struct({
|
||||
batch_tool: Schema.optional(Schema.Boolean).annotate({ description: "Enable the batch tool" }),
|
||||
codebase_search: Schema.optional(Schema.Boolean).annotate({ description: "Enable AI-powered codebase search" }), // kilocode_change
|
||||
// kilocode_change start
|
||||
semantic_indexing: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Enable semantic codebase indexing and the semantic_search tool",
|
||||
}),
|
||||
speech_to_text_model: Schema.optional(Schema.String).annotate({
|
||||
description: "Speech-to-text transcription model ID to use for voice input",
|
||||
}),
|
||||
|
||||
@@ -76,6 +76,7 @@ export namespace RemoteSender {
|
||||
readonly list: () => Promise<ReadonlyArray<Permission.Request>>
|
||||
readonly reply: (input: Permission.ReplyInput) => Promise<boolean>
|
||||
}
|
||||
prompt?: (input: SessionPrompt.PromptInput) => Promise<unknown>
|
||||
}
|
||||
|
||||
export type Sender = {
|
||||
@@ -97,6 +98,12 @@ export namespace RemoteSender {
|
||||
return AppRuntime.runPromise(Permission.Service.use((svc) => svc.reply(input)))
|
||||
},
|
||||
}
|
||||
const prompt =
|
||||
options.prompt ??
|
||||
(async (input: SessionPrompt.PromptInput) => {
|
||||
const { AppRuntime } = await import("@/effect/app-runtime")
|
||||
return AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.prompt(input)))
|
||||
})
|
||||
|
||||
const sub =
|
||||
options.subscribe ??
|
||||
@@ -294,7 +301,7 @@ export namespace RemoteSender {
|
||||
return
|
||||
}
|
||||
dispatchLongRunning(msg, directoryFor(input.data.sessionID), async () => {
|
||||
await SessionPrompt.prompt(input.data as SessionPrompt.PromptInput)
|
||||
await prompt(input.data as SessionPrompt.PromptInput)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -115,6 +115,14 @@ export namespace KilocodeConfig {
|
||||
return stripGlobalIndexing(info)
|
||||
}
|
||||
|
||||
export function retireIndexingFlag(info: Record<string, unknown>, source: string) {
|
||||
if (!isRecord(info.experimental) || !("semantic_indexing" in info.experimental)) return info
|
||||
const experimental = { ...info.experimental }
|
||||
delete experimental.semantic_indexing
|
||||
log.warn("ignored retired experimental.semantic_indexing config; use indexing.enabled instead", { path: source })
|
||||
return { ...info, experimental }
|
||||
}
|
||||
|
||||
function stripGlobalIndexing(info: Config.Info): Config.Info {
|
||||
// Indexing provider/storage settings can be global, but enablement is exposed separately from project enablement.
|
||||
if (info.indexing?.enabled === undefined) return info
|
||||
|
||||
@@ -9,7 +9,6 @@ type PluginSpec = string | [string, Record<string, unknown>]
|
||||
|
||||
type ConfigLike = {
|
||||
plugin?: readonly PluginSpec[] | null
|
||||
experimental?: { semantic_indexing?: boolean } | null
|
||||
}
|
||||
|
||||
type Req = {
|
||||
@@ -21,7 +20,7 @@ type LogLike = {
|
||||
}
|
||||
|
||||
export function indexingEnabled(config?: ConfigLike | null): boolean {
|
||||
return hasIndexingPlugin(config?.plugin ?? []) && config?.experimental?.semantic_indexing === true
|
||||
return hasIndexingPlugin(config?.plugin ?? [])
|
||||
}
|
||||
|
||||
export function resolveIndexingPlugin(req: Req, log?: LogLike): string {
|
||||
|
||||
@@ -242,15 +242,6 @@ export namespace KiloIndexing {
|
||||
return track(hit, await inert(() => missing()))
|
||||
}
|
||||
|
||||
if (cfg.experimental?.semantic_indexing !== true) {
|
||||
return track(
|
||||
hit,
|
||||
await inert(() =>
|
||||
disabledIndexingStatus("Semantic indexing is disabled. Enable it in the Experimental settings."),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (isWorktreePath(dir)) {
|
||||
return track(hit, await inert(() => worktreeDisabled()))
|
||||
}
|
||||
|
||||
@@ -10,4 +10,6 @@ Each task may provide a prompt, a short display name, and a branch name. Keep di
|
||||
|
||||
By default, multiple tasks are started as independent Agent Manager sessions. Set `versions` to true only when all tasks are alternate versions of the same work that should be compared together. Versioned worktrees are grouped in Agent Manager and branch names may receive version suffixes.
|
||||
|
||||
If available, use `kilo_local_recall` only if you need context from a completed Agent Manager session.
|
||||
|
||||
Do not use this for ordinary subagent research. Use the `task` tool for internal subagents, and use this only when the user wants visible Agent Manager sessions in the extension.
|
||||
|
||||
@@ -13,7 +13,6 @@ import { SessionID, MessageID, PartID } from "./schema"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { SessionRevert } from "./revert"
|
||||
import { makeRuntime } from "@/effect/run-service" // kilocode_change
|
||||
import * as Session from "./session"
|
||||
import { Agent } from "../agent/agent"
|
||||
import { Provider } from "@/provider/provider"
|
||||
@@ -2165,11 +2164,4 @@ const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi
|
||||
const placeholderRegex = /\$(\d+)/g
|
||||
const quoteTrimRegex = /^["']|["']$/g
|
||||
|
||||
// kilocode_change start - legacy promise helpers for Kilo callsites
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
export const prompt = (input: PromptInput) => runPromise((svc) => svc.prompt(input))
|
||||
export const loop = (input: LoopInput) => runPromise((svc) => svc.loop(input))
|
||||
export const cancel = (sessionID: SessionID) => runPromise((svc) => svc.cancel(sessionID))
|
||||
// kilocode_change end
|
||||
|
||||
export * as SessionPrompt from "./prompt"
|
||||
|
||||
@@ -54,9 +54,6 @@ async function writeConfig(dir: string, config: object, name = "kilo.json") {
|
||||
|
||||
const cfg: Partial<Config.Info> = {
|
||||
plugin: ["@kilocode/kilo-indexing"],
|
||||
experimental: {
|
||||
semantic_indexing: true,
|
||||
},
|
||||
indexing: {
|
||||
provider: "ollama",
|
||||
vectorStore: "qdrant",
|
||||
@@ -93,6 +90,22 @@ describe("markdown substitutions", () => {
|
||||
})
|
||||
|
||||
describe("kilocode indexing config", () => {
|
||||
test("ignores retired semantic indexing flags in existing configs", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await writeConfig(tmp.path, {
|
||||
experimental: { semantic_indexing: true, batch_tool: true },
|
||||
})
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
expect(config.experimental?.batch_tool).toBe(true)
|
||||
expect(config.experimental).not.toHaveProperty("semantic_indexing")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps global indexing enabled in global config", async () => {
|
||||
await using globalTmp = await tmpdir()
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
@@ -9,12 +9,8 @@ import {
|
||||
describe("indexing plugin helpers", () => {
|
||||
test("detects plugin-enabled configs", () => {
|
||||
expect(indexingEnabled({ plugin: ["global-plugin"] })).toBe(false)
|
||||
expect(indexingEnabled({ plugin: [INDEXING_PLUGIN] })).toBe(false)
|
||||
expect(indexingEnabled({ plugin: [INDEXING_PLUGIN], experimental: { semantic_indexing: false } })).toBe(false)
|
||||
expect(indexingEnabled({ plugin: [INDEXING_PLUGIN], experimental: { semantic_indexing: true } })).toBe(true)
|
||||
expect(
|
||||
indexingEnabled({ plugin: ["@kilocode/kilo-indexing@1.0.0"], experimental: { semantic_indexing: true } }),
|
||||
).toBe(true)
|
||||
expect(indexingEnabled({ plugin: [INDEXING_PLUGIN] })).toBe(true)
|
||||
expect(indexingEnabled({ plugin: ["@kilocode/kilo-indexing@1.0.0"] })).toBe(true)
|
||||
})
|
||||
|
||||
test("adds indexing plugin when present but missing from config", () => {
|
||||
|
||||
@@ -16,9 +16,6 @@ const fetch = global.fetch
|
||||
|
||||
const cfg: Partial<Config.Info> = {
|
||||
plugin: ["@kilocode/kilo-indexing"],
|
||||
experimental: {
|
||||
semantic_indexing: true,
|
||||
},
|
||||
indexing: {
|
||||
enabled: true,
|
||||
provider: "ollama",
|
||||
@@ -29,13 +26,9 @@ const cfg: Partial<Config.Info> = {
|
||||
},
|
||||
}
|
||||
|
||||
const off: Partial<Config.Info> = {
|
||||
const unset: Partial<Config.Info> = {
|
||||
plugin: ["@kilocode/kilo-indexing"],
|
||||
experimental: {
|
||||
semantic_indexing: false,
|
||||
},
|
||||
indexing: {
|
||||
enabled: true,
|
||||
provider: "ollama",
|
||||
vectorStore: "qdrant",
|
||||
ollama: {
|
||||
@@ -45,9 +38,6 @@ const off: Partial<Config.Info> = {
|
||||
}
|
||||
const inactive: Partial<Config.Info> = {
|
||||
plugin: ["@kilocode/kilo-indexing"],
|
||||
experimental: {
|
||||
semantic_indexing: true,
|
||||
},
|
||||
indexing: {
|
||||
enabled: false,
|
||||
provider: "ollama",
|
||||
@@ -56,9 +46,6 @@ const inactive: Partial<Config.Info> = {
|
||||
}
|
||||
const kilo: Partial<Config.Info> = {
|
||||
plugin: ["@kilocode/kilo-indexing"],
|
||||
experimental: {
|
||||
semantic_indexing: true,
|
||||
},
|
||||
indexing: {
|
||||
enabled: true,
|
||||
vectorStore: "qdrant",
|
||||
@@ -66,9 +53,6 @@ const kilo: Partial<Config.Info> = {
|
||||
}
|
||||
const implicitOpenAi: Partial<Config.Info> = {
|
||||
plugin: ["@kilocode/kilo-indexing"],
|
||||
experimental: {
|
||||
semantic_indexing: true,
|
||||
},
|
||||
indexing: {
|
||||
enabled: true,
|
||||
vectorStore: "qdrant",
|
||||
@@ -79,9 +63,6 @@ const implicitOpenAi: Partial<Config.Info> = {
|
||||
}
|
||||
const staleKilo: Partial<Config.Info> = {
|
||||
plugin: ["@kilocode/kilo-indexing"],
|
||||
experimental: {
|
||||
semantic_indexing: true,
|
||||
},
|
||||
indexing: {
|
||||
enabled: true,
|
||||
provider: "kilo",
|
||||
@@ -303,23 +284,23 @@ describe("indexing startup degradation", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("stays disabled when semantic indexing flag is off", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: off })
|
||||
test("stays disabled when indexing enablement is unset", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: unset })
|
||||
process.env["KILO_CONFIG_DIR"] = tmp.path
|
||||
const init = spyOn(CodeIndexManager.prototype, "initialize")
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const status = await KiloIndexing.current()
|
||||
const status = await wait(() => KiloIndexing.current(), "Disabled")
|
||||
|
||||
expect(status).toMatchObject({
|
||||
state: "Disabled",
|
||||
message: "Semantic indexing is disabled. Enable it in the Experimental settings.",
|
||||
message: "Indexing disabled.",
|
||||
})
|
||||
expect(await KiloIndexing.available()).toBe(false)
|
||||
expect(KiloIndexing.ready()).toBe(false)
|
||||
expect(await KiloIndexing.search("flag off")).toEqual([])
|
||||
expect(await KiloIndexing.search("disabled")).toEqual([])
|
||||
expect(init).not.toHaveBeenCalled()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -7,9 +7,6 @@ import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
|
||||
const cfg: Partial<Config.Info> = {
|
||||
plugin: ["@kilocode/kilo-indexing"],
|
||||
experimental: {
|
||||
semantic_indexing: true,
|
||||
},
|
||||
indexing: {
|
||||
enabled: true,
|
||||
provider: "ollama",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, spyOn, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { TuiEvent } from "../../src/cli/cmd/tui/event"
|
||||
@@ -1211,10 +1212,16 @@ describe("plan follow-up", () => {
|
||||
|
||||
expect(followup).toBeDefined()
|
||||
if (!followup) return
|
||||
expect(states.some((x) => x.sessionID === followup && x.type === "busy")).toBe(true)
|
||||
const sid = followup
|
||||
expect(states.some((x) => x.sessionID === sid && x.type === "busy")).toBe(true)
|
||||
|
||||
const { SessionPrompt } = await import("../../src/session/prompt")
|
||||
await SessionPrompt.cancel(followup)
|
||||
await Effect.runPromise(
|
||||
SessionPrompt.Service.use((svc) => svc.cancel(sid)).pipe(
|
||||
Effect.provide(SessionPrompt.defaultLayer),
|
||||
Effect.scoped,
|
||||
),
|
||||
)
|
||||
deferred.resolve("## Discoveries\n\nexample")
|
||||
await expect(pending).resolves.toBe("break")
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { SessionCompaction } from "../../src/session/compaction"
|
||||
import { SessionPrompt } from "../../src/session/prompt"
|
||||
import { MessageID, SessionID } from "../../src/session/schema"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { provideInstance, tmpdir } from "../fixture/fixture"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
@@ -60,10 +60,20 @@ function reply(input: { text: string; ready?: () => void; wait?: Promise<unknown
|
||||
})
|
||||
}
|
||||
|
||||
function hasText(msg: Awaited<ReturnType<typeof SessionPrompt.prompt>>, text: string) {
|
||||
function hasText(msg: MessageV2.WithParts, text: string) {
|
||||
return msg.parts.some((part) => part.type === "text" && part.text.includes(text))
|
||||
}
|
||||
|
||||
function scoped<T>(dir: string, fn: (prompt: SessionPrompt.Interface) => Promise<T>) {
|
||||
return Effect.runPromise(
|
||||
SessionPrompt.Service.use((prompt) => Effect.promise(() => fn(prompt))).pipe(
|
||||
Effect.provide(SessionPrompt.defaultLayer),
|
||||
provideInstance(dir),
|
||||
Effect.scoped,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Find the last non-system message in an OpenAI-compatible request body. Kept
|
||||
// tolerant: we only care about role invariants, not the exact content shape,
|
||||
// because providers may serialize `content` as a string or as a parts array.
|
||||
@@ -414,77 +424,82 @@ describe("session prompt queue", () => {
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({ title: "Queued prompt regression" })
|
||||
const first = SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "code",
|
||||
parts: [{ type: "text", text: "first prompt" }],
|
||||
})
|
||||
fn: async () =>
|
||||
scoped(tmp.path, async (prompt) => {
|
||||
const session = await Session.create({ title: "Queued prompt regression" })
|
||||
const first = Effect.runPromise(
|
||||
prompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "code",
|
||||
parts: [{ type: "text", text: "first prompt" }],
|
||||
}),
|
||||
)
|
||||
|
||||
await ready.promise
|
||||
await ready.promise
|
||||
|
||||
const second = SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "code",
|
||||
parts: [{ type: "text", text: "second prompt" }],
|
||||
})
|
||||
const second = Effect.runPromise(
|
||||
prompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "code",
|
||||
parts: [{ type: "text", text: "second prompt" }],
|
||||
}),
|
||||
)
|
||||
|
||||
const one = await first
|
||||
await injected.promise
|
||||
const two = await second
|
||||
const one = await first
|
||||
await injected.promise
|
||||
const two = await second
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls).toHaveLength(2)
|
||||
|
||||
// The in-flight stream must complete; no aborted error on msg1's reply.
|
||||
expect(one.info.role).toBe("assistant")
|
||||
if (one.info.role === "assistant") expect(one.info.error).toBeUndefined()
|
||||
expect(hasText(one, "first reply")).toBe(true)
|
||||
expect(hasText(two, "second reply")).toBe(true)
|
||||
// The in-flight stream must complete; no aborted error on msg1's reply.
|
||||
expect(one.info.role).toBe("assistant")
|
||||
if (one.info.role === "assistant") expect(one.info.error).toBeUndefined()
|
||||
expect(hasText(one, "first reply")).toBe(true)
|
||||
expect(hasText(two, "second reply")).toBe(true)
|
||||
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const users = msgs.filter((msg) => msg.info.role === "user")
|
||||
const assistants = msgs.filter((msg) => msg.info.role === "assistant")
|
||||
const prompts = users.flatMap((msg) =>
|
||||
msg.parts.filter((part) => part.type === "text").map((part) => part.text),
|
||||
)
|
||||
const text = assistants.flatMap((msg) =>
|
||||
msg.parts.filter((part) => part.type === "text").map((part) => part.text),
|
||||
)
|
||||
expect(users).toHaveLength(2)
|
||||
expect(assistants).toHaveLength(2)
|
||||
expect(prompts).toContain("first prompt")
|
||||
expect(prompts).toContain("second prompt")
|
||||
expect(text).toContain("first reply")
|
||||
expect(text).toContain("second reply")
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const users = msgs.filter((msg) => msg.info.role === "user")
|
||||
const assistants = msgs.filter((msg) => msg.info.role === "assistant")
|
||||
const prompts = users.flatMap((msg) =>
|
||||
msg.parts.filter((part) => part.type === "text").map((part) => part.text),
|
||||
)
|
||||
const text = assistants.flatMap((msg) =>
|
||||
msg.parts.filter((part) => part.type === "text").map((part) => part.text),
|
||||
)
|
||||
expect(users).toHaveLength(2)
|
||||
expect(assistants).toHaveLength(2)
|
||||
expect(prompts).toContain("first prompt")
|
||||
expect(prompts).toContain("second prompt")
|
||||
expect(text).toContain("first reply")
|
||||
expect(text).toContain("second reply")
|
||||
|
||||
const firstUser = users.find((msg) => hasText(msg, "first prompt"))
|
||||
const secondUser = users.find((msg) => hasText(msg, "second prompt"))
|
||||
const firstReply = assistants.find((msg) => hasText(msg, "first reply"))
|
||||
const secondReply = assistants.find((msg) => hasText(msg, "second reply"))
|
||||
if (
|
||||
firstUser?.info.role !== "user" ||
|
||||
secondUser?.info.role !== "user" ||
|
||||
firstReply?.info.role !== "assistant" ||
|
||||
secondReply?.info.role !== "assistant"
|
||||
) {
|
||||
throw new Error("missing expected messages")
|
||||
}
|
||||
expect(firstReply.info.parentID).toBe(firstUser.info.id)
|
||||
expect(secondReply.info.parentID).toBe(secondUser.info.id)
|
||||
const firstUser = users.find((msg) => hasText(msg, "first prompt"))
|
||||
const secondUser = users.find((msg) => hasText(msg, "second prompt"))
|
||||
const firstReply = assistants.find((msg) => hasText(msg, "first reply"))
|
||||
const secondReply = assistants.find((msg) => hasText(msg, "second reply"))
|
||||
if (
|
||||
firstUser?.info.role !== "user" ||
|
||||
secondUser?.info.role !== "user" ||
|
||||
firstReply?.info.role !== "assistant" ||
|
||||
secondReply?.info.role !== "assistant"
|
||||
) {
|
||||
throw new Error("missing expected messages")
|
||||
}
|
||||
expect(firstReply.info.parentID).toBe(firstUser.info.id)
|
||||
expect(secondReply.info.parentID).toBe(secondUser.info.id)
|
||||
|
||||
// Regression for #9492: the second LLM request must end with the
|
||||
// queued user prompt, not an assistant tail from the prior turn.
|
||||
// Anthropic's API rejects requests whose final message is assistant
|
||||
// (prefill), and scope() is supposed to partition the queued target
|
||||
// turn to the end before the model request is built.
|
||||
expect(bodies).toHaveLength(2)
|
||||
const second2 = bodies[1]
|
||||
expect(JSON.stringify(second2)).toContain("second prompt")
|
||||
const tail = lastConversational(second2)
|
||||
expect(tail?.role).toBe("user")
|
||||
expect(JSON.stringify(tail?.content)).toContain("second prompt")
|
||||
},
|
||||
// Regression for #9492: the second LLM request must end with the
|
||||
// queued user prompt, not an assistant tail from the prior turn.
|
||||
// Anthropic's API rejects requests whose final message is assistant
|
||||
// (prefill), and scope() is supposed to partition the queued target
|
||||
// turn to the end before the model request is built.
|
||||
expect(bodies).toHaveLength(2)
|
||||
const second2 = bodies[1]
|
||||
expect(JSON.stringify(second2)).toContain("second prompt")
|
||||
const tail = lastConversational(second2)
|
||||
expect(tail?.role).toBe("user")
|
||||
expect(JSON.stringify(tail?.content)).toContain("second prompt")
|
||||
}),
|
||||
})
|
||||
} finally {
|
||||
server.stop(true)
|
||||
@@ -531,52 +546,59 @@ describe("session prompt queue", () => {
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({ title: "Queued cancel regression" })
|
||||
const first = SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "code",
|
||||
parts: [{ type: "text", text: "first prompt" }],
|
||||
})
|
||||
await ready.promise
|
||||
fn: async () =>
|
||||
scoped(tmp.path, async (prompt) => {
|
||||
const session = await Session.create({ title: "Queued cancel regression" })
|
||||
const first = Effect.runPromise(
|
||||
prompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "code",
|
||||
parts: [{ type: "text", text: "first prompt" }],
|
||||
}),
|
||||
)
|
||||
await ready.promise
|
||||
|
||||
const second = SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "code",
|
||||
parts: [{ type: "text", text: "second prompt" }],
|
||||
})
|
||||
const third = SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "code",
|
||||
parts: [{ type: "text", text: "third prompt" }],
|
||||
})
|
||||
const second = Effect.runPromise(
|
||||
prompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "code",
|
||||
parts: [{ type: "text", text: "second prompt" }],
|
||||
}),
|
||||
)
|
||||
const third = Effect.runPromise(
|
||||
prompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "code",
|
||||
parts: [{ type: "text", text: "third prompt" }],
|
||||
}),
|
||||
)
|
||||
|
||||
// Let msg2/msg3's enqueue capture the current version before cancel bumps it.
|
||||
await Bun.sleep(20)
|
||||
expect(calls).toHaveLength(1)
|
||||
// Let msg2/msg3's enqueue capture the current version before cancel bumps it.
|
||||
await Bun.sleep(20)
|
||||
expect(calls).toHaveLength(1)
|
||||
|
||||
await SessionPrompt.cancel(session.id)
|
||||
await Promise.all([first, second, third])
|
||||
await Effect.runPromise(prompt.cancel(session.id))
|
||||
await Promise.all([first, second, third])
|
||||
|
||||
// The queued prompts must never reach the LLM once cancel flushes the queue.
|
||||
expect(calls).toHaveLength(1)
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const assistants = msgs.filter((msg) => msg.info.role === "assistant")
|
||||
expect(assistants).toHaveLength(1)
|
||||
expect(msgs.filter((msg) => msg.info.role === "user")).toHaveLength(3)
|
||||
// The queued prompts must never reach the LLM once cancel flushes the queue.
|
||||
expect(calls).toHaveLength(1)
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const assistants = msgs.filter((msg) => msg.info.role === "assistant")
|
||||
expect(assistants).toHaveLength(1)
|
||||
expect(msgs.filter((msg) => msg.info.role === "user")).toHaveLength(3)
|
||||
|
||||
// Internal state should have no lingering tail/version/target entries after the last release.
|
||||
const ids = await Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
session.id,
|
||||
MessageID.make("message_probe"),
|
||||
Effect.succeed(KiloSessionPromptQueue.scope(session.id, []).map((item) => item.info.id)),
|
||||
Effect.succeed([]),
|
||||
),
|
||||
)
|
||||
expect(ids).toEqual([])
|
||||
expect(KiloSessionPromptQueue.hasFollowup(session.id)).toBe(false)
|
||||
},
|
||||
// Internal state should have no lingering tail/version/target entries after the last release.
|
||||
const ids = await Effect.runPromise(
|
||||
KiloSessionPromptQueue.enqueue(
|
||||
session.id,
|
||||
MessageID.make("message_probe"),
|
||||
Effect.succeed(KiloSessionPromptQueue.scope(session.id, []).map((item) => item.info.id)),
|
||||
Effect.succeed([]),
|
||||
),
|
||||
)
|
||||
expect(ids).toEqual([])
|
||||
expect(KiloSessionPromptQueue.hasFollowup(session.id)).toBe(false)
|
||||
}),
|
||||
})
|
||||
} finally {
|
||||
server.stop(true)
|
||||
@@ -590,41 +612,44 @@ describe("session prompt queue", () => {
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({ title: "Suggestion unblock regression" })
|
||||
const offShown = Bus.subscribe(Suggestion.Event.Shown, (event) => {
|
||||
if (event.properties.sessionID === session.id) shown.resolve()
|
||||
})
|
||||
const offDismissed = Bus.subscribe(Suggestion.Event.Dismissed, (event) => {
|
||||
if (event.properties.sessionID === session.id) dismissed.resolve()
|
||||
})
|
||||
|
||||
try {
|
||||
const base = Suggestion.show({
|
||||
sessionID: session.id,
|
||||
text: "Run review?",
|
||||
actions: [{ label: "Review", prompt: "/local-review-uncommitted" }],
|
||||
}).catch((err) => {
|
||||
if (err instanceof Suggestion.DismissedError) return "dismissed"
|
||||
throw err
|
||||
fn: async () =>
|
||||
scoped(tmp.path, async (prompt) => {
|
||||
const session = await Session.create({ title: "Suggestion unblock regression" })
|
||||
const offShown = Bus.subscribe(Suggestion.Event.Shown, (event) => {
|
||||
if (event.properties.sessionID === session.id) shown.resolve()
|
||||
})
|
||||
const offDismissed = Bus.subscribe(Suggestion.Event.Dismissed, (event) => {
|
||||
if (event.properties.sessionID === session.id) dismissed.resolve()
|
||||
})
|
||||
|
||||
await shown.promise
|
||||
await SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "code",
|
||||
parts: [{ type: "text", text: "replacement prompt" }],
|
||||
noReply: true,
|
||||
})
|
||||
await dismissed.promise
|
||||
try {
|
||||
const base = Suggestion.show({
|
||||
sessionID: session.id,
|
||||
text: "Run review?",
|
||||
actions: [{ label: "Review", prompt: "/local-review-uncommitted" }],
|
||||
}).catch((err) => {
|
||||
if (err instanceof Suggestion.DismissedError) return "dismissed"
|
||||
throw err
|
||||
})
|
||||
|
||||
expect(await base).toBe("dismissed")
|
||||
expect(await Suggestion.list()).toEqual([])
|
||||
} finally {
|
||||
offShown()
|
||||
offDismissed()
|
||||
}
|
||||
},
|
||||
await shown.promise
|
||||
await Effect.runPromise(
|
||||
prompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "code",
|
||||
parts: [{ type: "text", text: "replacement prompt" }],
|
||||
noReply: true,
|
||||
}),
|
||||
)
|
||||
await dismissed.promise
|
||||
|
||||
expect(await base).toBe("dismissed")
|
||||
expect(await Suggestion.list()).toEqual([])
|
||||
} finally {
|
||||
offShown()
|
||||
offDismissed()
|
||||
}
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -635,49 +660,52 @@ describe("session prompt queue", () => {
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({ title: "Question unblock regression" })
|
||||
const offAsked = Bus.subscribe(Question.Event.Asked, (event) => {
|
||||
if (event.properties.sessionID === session.id) asked.resolve()
|
||||
})
|
||||
const offRejected = Bus.subscribe(Question.Event.Rejected, (event) => {
|
||||
if (event.properties.sessionID === session.id) rejected.resolve()
|
||||
})
|
||||
|
||||
try {
|
||||
const pending = Question.ask({
|
||||
sessionID: session.id,
|
||||
questions: [
|
||||
{
|
||||
header: "Continue?",
|
||||
question: "Should I continue?",
|
||||
options: [
|
||||
{ label: "Yes", description: "Go ahead" },
|
||||
{ label: "No", description: "Stop" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}).catch((err) => {
|
||||
if (err instanceof Question.RejectedError) return "rejected"
|
||||
throw err
|
||||
fn: async () =>
|
||||
scoped(tmp.path, async (prompt) => {
|
||||
const session = await Session.create({ title: "Question unblock regression" })
|
||||
const offAsked = Bus.subscribe(Question.Event.Asked, (event) => {
|
||||
if (event.properties.sessionID === session.id) asked.resolve()
|
||||
})
|
||||
const offRejected = Bus.subscribe(Question.Event.Rejected, (event) => {
|
||||
if (event.properties.sessionID === session.id) rejected.resolve()
|
||||
})
|
||||
|
||||
await asked.promise
|
||||
await SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "code",
|
||||
parts: [{ type: "text", text: "replacement prompt" }],
|
||||
noReply: true,
|
||||
})
|
||||
await rejected.promise
|
||||
try {
|
||||
const pending = Question.ask({
|
||||
sessionID: session.id,
|
||||
questions: [
|
||||
{
|
||||
header: "Continue?",
|
||||
question: "Should I continue?",
|
||||
options: [
|
||||
{ label: "Yes", description: "Go ahead" },
|
||||
{ label: "No", description: "Stop" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}).catch((err) => {
|
||||
if (err instanceof Question.RejectedError) return "rejected"
|
||||
throw err
|
||||
})
|
||||
|
||||
expect(await pending).toBe("rejected")
|
||||
expect(await Question.list()).toEqual([])
|
||||
} finally {
|
||||
offAsked()
|
||||
offRejected()
|
||||
}
|
||||
},
|
||||
await asked.promise
|
||||
await Effect.runPromise(
|
||||
prompt.prompt({
|
||||
sessionID: session.id,
|
||||
agent: "code",
|
||||
parts: [{ type: "text", text: "replacement prompt" }],
|
||||
noReply: true,
|
||||
}),
|
||||
)
|
||||
await rejected.promise
|
||||
|
||||
expect(await pending).toBe("rejected")
|
||||
expect(await Question.list()).toEqual([])
|
||||
} finally {
|
||||
offAsked()
|
||||
offRejected()
|
||||
}
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@ import { Effect } from "effect"
|
||||
import { RemoteSender } from "../../../src/kilo-sessions/remote-sender"
|
||||
import type { RemoteWS } from "../../../src/kilo-sessions/remote-ws"
|
||||
import type { RemoteProtocol } from "../../../src/kilo-sessions/remote-protocol"
|
||||
import { SessionPrompt } from "../../../src/session/prompt"
|
||||
import type { SessionPrompt } from "../../../src/session/prompt"
|
||||
import { Question } from "../../../src/question"
|
||||
import { Permission } from "../../../src/permission"
|
||||
import { PermissionID } from "../../../src/permission/schema"
|
||||
import { ModelID, ProviderID } from "../../../src/provider/schema"
|
||||
import { SessionID } from "../../../src/session/schema"
|
||||
import { Suggestion } from "../../../src/kilocode/suggestion" // kilocode_change
|
||||
|
||||
function fakeConn() {
|
||||
@@ -55,6 +57,12 @@ function permissions(items: Permission.Request[] = []) {
|
||||
}
|
||||
}
|
||||
|
||||
function prompts(calls: SessionPrompt.PromptInput[]) {
|
||||
return async (input: SessionPrompt.PromptInput) => {
|
||||
calls.push(input)
|
||||
}
|
||||
}
|
||||
|
||||
// kilocode_change start
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
@@ -321,13 +329,14 @@ describe("RemoteSender", () => {
|
||||
// kilocode_change start
|
||||
test("send_message normalizes string model without prefix", async () => {
|
||||
const { conn, sent } = fakeConn()
|
||||
const prompt = spyOn(SessionPrompt, "prompt").mockResolvedValue({} as never)
|
||||
const calls: SessionPrompt.PromptInput[] = []
|
||||
const sender = RemoteSender.create({
|
||||
conn,
|
||||
directory: "/tmp/test",
|
||||
log: nolog,
|
||||
subscribe: fakeBus().subscribe,
|
||||
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
|
||||
prompt: prompts(calls),
|
||||
})
|
||||
|
||||
sender.handle({
|
||||
@@ -344,22 +353,25 @@ describe("RemoteSender", () => {
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(sent[0]).toEqual({ type: "response", id: "req_model_string", result: {} })
|
||||
expect(prompt).toHaveBeenCalledWith({
|
||||
sessionID: "ses_x",
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
model: { providerID: "kilo", modelID: "anthropic/claude-sonnet-4-20250514" },
|
||||
})
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
sessionID: SessionID.make("ses_x"),
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
model: { providerID: ProviderID.make("kilo"), modelID: ModelID.make("anthropic/claude-sonnet-4-20250514") },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("send_message keeps kilocode-prefixed model unchanged before internal conversion", async () => {
|
||||
const { conn } = fakeConn()
|
||||
const prompt = spyOn(SessionPrompt, "prompt").mockResolvedValue({} as never)
|
||||
const calls: SessionPrompt.PromptInput[] = []
|
||||
const sender = RemoteSender.create({
|
||||
conn,
|
||||
directory: "/tmp/test",
|
||||
log: nolog,
|
||||
subscribe: fakeBus().subscribe,
|
||||
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
|
||||
prompt: prompts(calls),
|
||||
})
|
||||
|
||||
sender.handle({
|
||||
@@ -375,11 +387,13 @@ describe("RemoteSender", () => {
|
||||
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(prompt).toHaveBeenCalledWith({
|
||||
sessionID: "ses_x",
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
model: { providerID: "kilo", modelID: "gpt-5-mini" },
|
||||
})
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
sessionID: SessionID.make("ses_x"),
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
model: { providerID: ProviderID.make("kilo"), modelID: ModelID.make("gpt-5-mini") },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("send_message rejects structured model on remote path", () => {
|
||||
@@ -411,13 +425,14 @@ describe("RemoteSender", () => {
|
||||
|
||||
test("send_message does not special-case kilo-prefixed model", async () => {
|
||||
const { conn, sent } = fakeConn()
|
||||
const prompt = spyOn(SessionPrompt, "prompt").mockResolvedValue({} as never)
|
||||
const calls: SessionPrompt.PromptInput[] = []
|
||||
const sender = RemoteSender.create({
|
||||
conn,
|
||||
directory: "/tmp/test",
|
||||
log: nolog,
|
||||
subscribe: fakeBus().subscribe,
|
||||
provide: async <R>(input: { directory: string; init?: Effect.Effect<void>; fn: () => R }) => input.fn(),
|
||||
prompt: prompts(calls),
|
||||
})
|
||||
|
||||
sender.handle({
|
||||
@@ -434,11 +449,13 @@ describe("RemoteSender", () => {
|
||||
await new Promise((r) => setTimeout(r, 0))
|
||||
|
||||
expect(sent[0]).toEqual({ type: "response", id: "req_model_kilo", result: {} })
|
||||
expect(prompt).toHaveBeenCalledWith({
|
||||
sessionID: "ses_x",
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
model: { providerID: "kilo", modelID: "kilo/gpt-5-mini" },
|
||||
})
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
sessionID: SessionID.make("ses_x"),
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
model: { providerID: ProviderID.make("kilo"), modelID: ModelID.make("kilo/gpt-5-mini") },
|
||||
},
|
||||
])
|
||||
})
|
||||
// kilocode_change end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user