mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
fix(gateway): prevent duplicate streamed tool calls
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user