fix(gateway): prevent duplicate streamed tool calls

This commit is contained in:
Josh Lambert
2026-06-10 17:05:06 -04:00
parent 34f94e3b16
commit 3f9c025bbc
11 changed files with 101 additions and 86 deletions
@@ -1,5 +1,7 @@
---
"kilo-code": patch
"@kilocode/cli": patch
"@kilocode/kilo-gateway": patch
---
Dismiss answered question prompts immediately and restore them when submission fails.
Prevent streamed tool calls from executing twice and leaving answered questions disabled in VS Code.
+1 -3
View File
@@ -171,7 +171,7 @@
"@clack/prompts": "1.0.0-alpha.1",
"@kilocode/plugin": "workspace:*",
"@kilocode/sdk": "workspace:*",
"@openrouter/ai-sdk-provider": "2.8.1",
"@openrouter/ai-sdk-provider": "2.9.0",
"ai": "catalog:",
"open": "10.1.2",
"zod": "catalog:",
@@ -4649,8 +4649,6 @@
"@kilocode/kilo-docs/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"@kilocode/kilo-gateway/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.8.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Y6j3yivgoEUf/kutD/k5GX/mzZfioRFoSx0gbQ+mIOzMaH/vJv1rCkztiuvlLw5xRYQil7oxHUZvmSfXqOx1NQ=="],
"@kilocode/kilo-indexing/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
"@manypkg/find-root/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
+1 -1
View File
@@ -39,7 +39,7 @@
"@ai-sdk/openai": "3.0.53",
"@ai-sdk/openai-compatible": "2.0.41",
"@ai-sdk/mistral": "3.0.27",
"@openrouter/ai-sdk-provider": "2.8.1",
"@openrouter/ai-sdk-provider": "2.9.0",
"@clack/prompts": "1.0.0-alpha.1",
"ai": "catalog:",
"open": "10.1.2",
+89 -1
View File
@@ -1,5 +1,7 @@
import { describe, expect, test } from "bun:test"
import { buildRequestHeaders } from "../src/provider"
import { streamText, tool } from "ai"
import { z } from "zod"
import { buildRequestHeaders, createKilo } from "../src/provider"
describe("Kilo provider request headers", () => {
test("request headers override provider defaults", () => {
@@ -21,3 +23,89 @@ describe("Kilo provider request headers", () => {
expect(headers.get("x-request-only")).toBe("kept-too")
})
})
describe("Kilo provider tool streaming", () => {
test("executes a tool once when complete arguments are followed by whitespace", async () => {
const chunks = [
{
choices: [
{
index: 0,
delta: {
role: "assistant",
content: null,
tool_calls: [
{
index: 0,
id: "call_question",
type: "function",
function: { name: "question", arguments: '{"answer":"lasagna"}' },
},
],
},
logprobs: null,
finish_reason: null,
},
],
},
{
choices: [
{
index: 0,
delta: {
tool_calls: [{ index: 0, function: { arguments: " " } }],
},
logprobs: null,
finish_reason: null,
},
],
},
{
choices: [{ index: 0, delta: {}, logprobs: null, finish_reason: "tool_calls" }],
},
{
choices: [],
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
},
].map((chunk) =>
JSON.stringify({
id: "chatcmpl-question",
object: "chat.completion.chunk",
created: 1711357598,
model: "qwen/qwen3.6-plus",
...chunk,
}),
)
const body = [...chunks.map((chunk) => `data: ${chunk}\n\n`), "data: [DONE]\n\n"].join("")
const fetcher = async () =>
new Response(body, {
status: 200,
headers: { "content-type": "text/event-stream" },
})
const provider = createKilo({
apiKey: "test",
baseURL: "https://gateway.test/api/openrouter/",
fetch: fetcher as typeof fetch,
})
const calls: Array<{ answer: string }> = []
const result = streamText({
model: provider.languageModel("qwen/qwen3.6-plus"),
prompt: "Ask a question",
tools: {
question: tool({
description: "Ask the user a question",
inputSchema: z.object({ answer: z.string() }),
execute: async (input) => {
calls.push(input)
return input.answer
},
}),
},
})
const events = []
for await (const event of result.fullStream) events.push(event)
expect(events.filter((event) => event.type === "tool-call")).toHaveLength(1)
expect(calls).toEqual([{ answer: "lasagna" }])
})
})
@@ -71,7 +71,6 @@ export async function handleQuestionReply(
{ requestID, answers, directory: ctx.getWorkspaceDirectory(sid) },
{ throwOnError: true },
)
ctx.postMessage({ type: "questionResolved", requestID })
return true
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to reply to question:", error)
@@ -95,7 +94,6 @@ export async function handleQuestionReject(
try {
await ctx.client.question.reject({ requestID, directory: ctx.getWorkspaceDirectory(sid) }, { throwOnError: true })
ctx.postMessage({ type: "questionResolved", requestID })
return true
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to reject question:", error)
@@ -11,7 +11,6 @@ import path from "node:path"
const ROOT = path.resolve(import.meta.dir, "../..")
const FILE = path.join(ROOT, "webview-ui/src/components/chat/QuestionDock.tsx")
const ASSISTANT = path.join(ROOT, "webview-ui/src/components/chat/AssistantMessage.tsx")
function readFile(filePath: string): string {
return fs.readFileSync(filePath, "utf-8")
@@ -59,9 +58,4 @@ describe("QuestionDock explicit submit contract", () => {
it("keeps the footer Submit button wired to submit()", () => {
expect(source).toContain('<Button variant="primary" size="small" onClick={submit} disabled={store.sending}>')
})
it("remounts the dock when an inline tool receives a new request", () => {
const assistant = readFile(ASSISTANT)
expect(assistant).toMatch(/<Show\s+when=\{activeQuestion\(\)\}\s+keyed/)
})
})
@@ -5,7 +5,6 @@ import { handleQuestionReject, handleQuestionReply } from "../../src/kilo-provid
describe("question handlers", () => {
it("routes replies using the question session when provided", async () => {
const calls: Array<Record<string, unknown>> = []
const messages: unknown[] = []
const client = {
question: {
reply: async (input: Record<string, unknown>) => {
@@ -20,9 +19,7 @@ describe("question handlers", () => {
{
client,
currentSessionId: "ses-root",
postMessage(message) {
messages.push(message)
},
postMessage() {},
getWorkspaceDirectory(sessionId) {
return sessionId ? `/repo/${sessionId}` : "/repo"
},
@@ -40,7 +37,6 @@ describe("question handlers", () => {
directory: "/repo/ses-worktree",
},
])
expect(messages).toEqual([{ type: "questionResolved", requestID: "req-1" }])
})
it("falls back to the current session when no question session is provided", async () => {
@@ -74,7 +70,6 @@ describe("question handlers", () => {
it("routes rejects using the question session when provided", async () => {
const calls: Array<Record<string, unknown>> = []
const messages: unknown[] = []
const client = {
question: {
reply: async () => true,
@@ -89,9 +84,7 @@ describe("question handlers", () => {
{
client,
currentSessionId: "ses-root",
postMessage(message) {
messages.push(message)
},
postMessage() {},
getWorkspaceDirectory(sessionId) {
return sessionId ? `/repo/${sessionId}` : "/repo"
},
@@ -107,6 +100,5 @@ describe("question handlers", () => {
directory: "/repo/ses-worktree",
},
])
expect(messages).toEqual([{ type: "questionResolved", requestID: "req-3" }])
})
})
@@ -1,29 +0,0 @@
import { describe, expect, it } from "bun:test"
import type { QuestionRequest } from "../../webview-ui/src/types/messages"
import { removeQuestion, restoreQuestion } from "../../webview-ui/src/context/question-queue"
const first: QuestionRequest = {
id: "req-1",
sessionID: "ses-1",
questions: [],
}
const second: QuestionRequest = {
id: "req-2",
sessionID: "ses-1",
questions: [],
}
describe("question queue", () => {
it("removes only the question being answered", () => {
const result = removeQuestion([first, second], first.id)
expect(result.question).toBe(first)
expect(result.questions).toEqual([second])
})
it("restores a failed question without duplicating it", () => {
expect(restoreQuestion([second], first)).toEqual([second, first])
expect(restoreQuestion([first, second], first)).toEqual([first, second])
})
})
@@ -225,7 +225,6 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
<div data-component="tool-part-wrapper" data-part-type={part.type}>
<Show
when={activeQuestion()}
keyed
fallback={
<Show
when={activeSuggestion()}
@@ -269,7 +268,7 @@ export const AssistantMessage: Component<AssistantMessageProps> = (props) => {
</Show>
}
>
{(req) => <QuestionDock request={req} />}
{(req) => <QuestionDock request={req()} />}
</Show>
</div>
</Show>
@@ -1,15 +0,0 @@
import type { QuestionRequest } from "../types/messages"
export function removeQuestion(questions: QuestionRequest[], id: string) {
const question = questions.find((item) => item.id === id)
if (!question) return { question, questions }
return {
question,
questions: questions.filter((item) => item.id !== id),
}
}
export function restoreQuestion(questions: QuestionRequest[], question: QuestionRequest | undefined) {
if (!question || questions.some((item) => item.id === question.id)) return questions
return [...questions, question]
}
@@ -71,7 +71,6 @@ import { state as todoState } from "./todo-revert"
import { getVariant, sessionVariantKeys, transferVariants, variantKey } from "./session-variant-store"
import { KILO_AUTO, parseModelString } from "../../../src/shared/provider-model"
import { visibleMessages as filterVisibleMessages } from "./session-queue"
import { removeQuestion, restoreQuestion } from "./question-queue"
const RECENT_LIMIT = 5
const MESSAGE_PAGE_LIMIT = 80
@@ -343,7 +342,6 @@ export const SessionProvider: ParentComponent = (props) => {
// Pending questions
const [questions, setQuestions] = createSignal<QuestionRequest[]>([])
const respondingQuestions = new Map<string, QuestionRequest>()
// Tracks question IDs that failed so the UI can reset sending state
const [questionErrors, setQuestionErrors] = createSignal<Set<string>>(new Set())
@@ -945,7 +943,6 @@ export const SessionProvider: ParentComponent = (props) => {
case "clearPendingPrompts":
setPermissions([])
setQuestions([])
respondingQuestions.clear()
setSuggestions([])
setRespondingPermissions(new Set<string>())
setSuggestionErrors(new Set<string>())
@@ -1457,7 +1454,6 @@ export const SessionProvider: ParentComponent = (props) => {
}
function handleQuestionRequest(question: QuestionRequest) {
if (respondingQuestions.has(question.id)) return
setQuestions((prev) => {
const idx = prev.findIndex((q) => q.id === question.id)
if (idx === -1) return [...prev, question]
@@ -1468,7 +1464,6 @@ export const SessionProvider: ParentComponent = (props) => {
}
function handleQuestionResolved(requestID: string) {
respondingQuestions.delete(requestID)
setQuestions((prev) => prev.filter((q) => q.id !== requestID))
setQuestionErrors((prev) => {
const next = new Set(prev)
@@ -1478,9 +1473,6 @@ export const SessionProvider: ParentComponent = (props) => {
}
function handleQuestionError(requestID: string) {
const question = respondingQuestions.get(requestID)
respondingQuestions.delete(requestID)
setQuestions((prev) => restoreQuestion(prev, question))
setQuestionErrors((prev) => new Set(prev).add(requestID))
}
@@ -2173,10 +2165,8 @@ export const SessionProvider: ParentComponent = (props) => {
function replyToQuestion(requestID: string, answers: string[][]) {
clearQuestionError(requestID)
const next = removeQuestion(questions(), requestID)
const sessionID = next.question?.sessionID ?? currentSessionID() ?? ""
if (next.question) respondingQuestions.set(requestID, next.question)
setQuestions(next.questions)
const question = questions().find((item) => item.id === requestID)
const sessionID = question?.sessionID ?? currentSessionID() ?? ""
vscode.postMessage({
type: "questionReply",
requestID,
@@ -2187,10 +2177,8 @@ export const SessionProvider: ParentComponent = (props) => {
function rejectQuestion(requestID: string) {
clearQuestionError(requestID)
const next = removeQuestion(questions(), requestID)
const sessionID = next.question?.sessionID ?? currentSessionID() ?? ""
if (next.question) respondingQuestions.set(requestID, next.question)
setQuestions(next.questions)
const question = questions().find((item) => item.id === requestID)
const sessionID = question?.sessionID ?? currentSessionID() ?? ""
vscode.postMessage({
type: "questionReject",
requestID,