Merge remote-tracking branch 'origin/main' into valley-hawk

This commit is contained in:
marius-kilocode
2026-05-29 14:52:30 +02:00
109 changed files with 1708 additions and 761 deletions
+1 -4
View File
@@ -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.
-8
View File
@@ -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