From 2f93185b0c4a79d70c35776152ff4392782312aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Mon, 1 Jun 2026 17:59:14 -0300 Subject: [PATCH 1/5] fix: suppress false incomplete response warnings --- .changeset/calm-snapshot-warnings.md | 5 + .changeset/tidy-responses-finish.md | 5 + .../tests/unit/session-outcome.test.ts | 67 ++++++++++- .../src/components/shared/TurnOutcome.tsx | 1 + .../webview-ui/src/context/session-outcome.ts | 22 +++- .../webview-ui/src/context/session-utils.ts | 20 ++++ .../openai-responses-language-model.ts | 15 +++ .../provider/openai-responses-finish.test.ts | 104 ++++++++++++++++++ 8 files changed, 234 insertions(+), 5 deletions(-) create mode 100644 .changeset/calm-snapshot-warnings.md create mode 100644 .changeset/tidy-responses-finish.md create mode 100644 packages/opencode/test/kilocode/provider/openai-responses-finish.test.ts diff --git a/.changeset/calm-snapshot-warnings.md b/.changeset/calm-snapshot-warnings.md new file mode 100644 index 00000000000..507f2bc11d5 --- /dev/null +++ b/.changeset/calm-snapshot-warnings.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Avoid showing incomplete-response warnings for snapshot initialization status turns. diff --git a/.changeset/tidy-responses-finish.md b/.changeset/tidy-responses-finish.md new file mode 100644 index 00000000000..6f569648c52 --- /dev/null +++ b/.changeset/tidy-responses-finish.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Avoid showing unexpected incomplete-response warnings when Responses streams finish after a completed tool call without a terminal event. diff --git a/packages/kilo-vscode/tests/unit/session-outcome.test.ts b/packages/kilo-vscode/tests/unit/session-outcome.test.ts index 8c4397b98ef..971a9992e9c 100644 --- a/packages/kilo-vscode/tests/unit/session-outcome.test.ts +++ b/packages/kilo-vscode/tests/unit/session-outcome.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test" import { terminal } from "../../webview-ui/src/context/session-outcome" -import type { Message, TodoItem } from "../../webview-ui/src/types/messages" +import type { Message, Part, TodoItem } from "../../webview-ui/src/types/messages" function message(finish?: string, error?: Message["error"]): Message { return { @@ -17,6 +17,27 @@ function todo(status: TodoItem["status"]): TodoItem { return { id: status, content: status, status } } +function snapshotMessage(id = "snapshot"): Message { + return { + ...message("other"), + id, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + } +} + +function snapshotParts(reason = "other"): Part[] { + return [ + { id: "start", type: "step-start" }, + { id: "progress", type: "text", text: "Initializing snapshot...", synthetic: true }, + { id: "finish", type: "step-finish", reason }, + ] +} + +function lookup(id: string, parts: Part[]): (msg: Message) => Part[] | undefined { + return (msg) => (msg.id === id ? parts : msg.parts) +} + describe("terminal", () => { it("returns no terminal state before a turn closes", () => { expect(terminal({ messages: [message("stop")], todos: [] })).toBeUndefined() @@ -48,6 +69,50 @@ describe("terminal", () => { expect(terminal({ reason: "completed", messages: [message("other")], todos: [] })?.kind).toBe("unexpected") }) + it("ignores snapshot-only assistant tails when choosing the terminal finish", () => { + const real = { ...message("length"), id: "real" } + const snap = snapshotMessage() + + expect( + terminal({ + reason: "completed", + messages: [real, snap], + todos: [], + parts: lookup(snap.id, snapshotParts()), + }), + ).toEqual({ kind: "limit", tone: "warning", finish: "length", remaining: 0 }) + }) + + it("uses inline parts to ignore snapshot-only assistant tails", () => { + const real = { ...message("stop"), id: "real" } + const snap = { ...snapshotMessage(), parts: snapshotParts() } + + expect(terminal({ reason: "completed", messages: [real, snap], todos: [todo("pending")] })).toEqual({ + kind: "incomplete", + tone: "warning", + finish: "stop", + remaining: 1, + }) + }) + + it("keeps unexpected warnings for real other finishes", () => { + const snap = { + ...snapshotMessage(), + parts: [...snapshotParts(), { id: "real", type: "text", text: "Actual assistant text" } satisfies Part], + } + + expect(terminal({ reason: "completed", messages: [snap], todos: [] })?.kind).toBe("unexpected") + }) + + it("requires synthetic snapshot progress before ignoring an other finish", () => { + const snap = { + ...snapshotMessage(), + parts: [{ id: "progress", type: "text", text: "Initializing snapshot..." } satisfies Part], + } + + expect(terminal({ reason: "completed", messages: [snap], todos: [] })?.kind).toBe("unexpected") + }) + it("surfaces interruption and failures without a rendered error", () => { expect(terminal({ reason: "interrupted", messages: [message("stop")], todos: [todo("pending")] })).toEqual({ kind: "interrupted", diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/TurnOutcome.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/TurnOutcome.tsx index 9bacd25fb00..992bff47fc7 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/TurnOutcome.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/TurnOutcome.tsx @@ -12,6 +12,7 @@ export const TurnOutcome: Component = () => { reason: session.closeReason(), messages: session.visibleMessages(), todos: session.todos(), + parts: (msg) => session.getParts(msg.id), hidden: session.isErrorHidden, }), ) diff --git a/packages/kilo-vscode/webview-ui/src/context/session-outcome.ts b/packages/kilo-vscode/webview-ui/src/context/session-outcome.ts index 111125f9513..d29be5f87de 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-outcome.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-outcome.ts @@ -1,4 +1,5 @@ -import type { Message, SessionCloseReason, TodoItem } from "../types/messages" +import type { Message, Part, SessionCloseReason, TodoItem } from "../types/messages" +import { snapshotOnlyAssistant } from "./session-utils" type TerminalKind = "incomplete" | "limit" | "unknown" | "filtered" | "unexpected" | "interrupted" | "error" type TerminalTone = "warning" | "critical" @@ -14,18 +15,31 @@ interface Input { reason?: SessionCloseReason messages: Message[] todos: TodoItem[] + parts?: (msg: Message) => Part[] | undefined hidden?: (id: string) => boolean } +function last(input: Input): Message | undefined { + for (let i = input.messages.length - 1; i >= 0; i -= 1) { + const msg = input.messages[i] + if (!msg) continue + if (msg.role !== "assistant") return undefined + const parts = input.parts?.(msg) ?? msg.parts + if (snapshotOnlyAssistant(msg, parts)) continue + return msg + } + return undefined +} + export function terminal(input: Input): TerminalState | undefined { if (!input.reason) return undefined - const last = input.messages[input.messages.length - 1] - const finish = last?.role === "assistant" ? last.finish : undefined + const msg = last(input) + const finish = msg?.finish const remaining = input.todos.filter((item) => item.status !== "completed" && item.status !== "cancelled").length if (input.reason === "interrupted") return { kind: "interrupted", tone: "warning", finish, remaining } if (input.reason === "error") { - if (last?.role === "assistant" && last.error && !input.hidden?.(last.id)) return undefined + if (msg?.error && !input.hidden?.(msg.id)) return undefined return { kind: "error", tone: "critical", finish, remaining } } if (finish === "length") return { kind: "limit", tone: "warning", finish, remaining } diff --git a/packages/kilo-vscode/webview-ui/src/context/session-utils.ts b/packages/kilo-vscode/webview-ui/src/context/session-utils.ts index 9e7798119a7..b42a7ef6cc9 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-utils.ts @@ -14,6 +14,26 @@ export function snapshotProgress(part: SnapshotPart | undefined): boolean { return (part.text ?? "").includes("Initializing snapshot") } +function tokenCount(tokens: Message["tokens"] | undefined): number { + if (!tokens) return 0 + return tokens.input + tokens.output + (tokens.reasoning ?? 0) + (tokens.cache?.read ?? 0) + (tokens.cache?.write ?? 0) +} + +export function snapshotOnlyAssistant(msg: Message, parts: Part[] | undefined): boolean { + if (msg.role !== "assistant") return false + if (msg.finish !== "other") return false + if (msg.error) return false + if ((msg.cost ?? 0) !== 0) return false + if (tokenCount(msg.tokens) !== 0) return false + if (!parts?.length) return false + + const snapshot = parts.some(snapshotProgress) + const allowed = parts.every( + (part) => snapshotProgress(part) || part.type === "step-start" || part.type === "step-finish", + ) + return snapshot && allowed +} + type ParentSession = { parentID?: string | null } type RecentSession = ParentSession & { updatedAt: string } diff --git a/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts b/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts index 250d1f6f340..1a7f960e53e 100644 --- a/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts +++ b/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts @@ -803,6 +803,9 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { unified: "other", raw: undefined, } + // kilocode_change start - avoid surfacing "other" when a stream omits the terminal response chunk + let hasResponseFinished = false + // kilocode_change end const usage: { inputTokens: number | undefined outputTokens: number | undefined @@ -1266,6 +1269,9 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { }), raw: value.response.incomplete_details?.reason ?? undefined, } + // kilocode_change start + hasResponseFinished = true + // kilocode_change end usage.inputTokens = value.response.usage.input_tokens usage.outputTokens = value.response.usage.output_tokens usage.totalTokens = value.response.usage.input_tokens + value.response.usage.output_tokens @@ -1319,6 +1325,15 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { providerMetadata.openai.serviceTier = serviceTier } + // kilocode_change start - classify missing terminal chunks by observed output instead of "other" + if (!hasResponseFinished && finishReason.unified === "other") { + finishReason = { + unified: mapOpenAIResponseFinishReason({ finishReason: undefined, hasFunctionCall }), + raw: undefined, + } + } + // kilocode_change end + controller.enqueue({ type: "finish", finishReason, diff --git a/packages/opencode/test/kilocode/provider/openai-responses-finish.test.ts b/packages/opencode/test/kilocode/provider/openai-responses-finish.test.ts new file mode 100644 index 00000000000..fcd7bcfdd14 --- /dev/null +++ b/packages/opencode/test/kilocode/provider/openai-responses-finish.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "bun:test" +import type { LanguageModelV3Prompt, LanguageModelV3StreamPart } from "@ai-sdk/provider" +import { OpenAIResponsesLanguageModel } from "../../../src/provider/sdk/copilot/responses/openai-responses-language-model" + +const prompt: LanguageModelV3Prompt = [{ role: "user", content: [{ type: "text", text: "Hello" }] }] + +function stream(chunks: unknown[]) { + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`)) + } + controller.close() + }, + }) +} + +function model(chunks: unknown[]) { + const fetch: typeof globalThis.fetch = Object.assign( + async () => + new Response(stream(chunks), { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + { preconnect: () => {} }, + ) + + return new OpenAIResponsesLanguageModel("test-model", { + provider: "test", + url: () => "https://api.test.com/responses", + headers: () => ({}), + fetch, + }) +} + +async function parts(chunks: unknown[]) { + const result = await model(chunks).doStream({ prompt, includeRawChunks: false }) + const out: LanguageModelV3StreamPart[] = [] + for await (const part of result.stream) out.push(part) + return out +} + +describe("OpenAI Responses finish mapping", () => { + test("treats a completed tool call without a terminal response chunk as tool-calls", async () => { + const out = await parts([ + { + type: "response.created", + response: { id: "resp_test", created_at: 1, model: "test-model", service_tier: null }, + }, + { + type: "response.output_item.added", + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "todowrite", arguments: "" }, + }, + { + type: "response.function_call_arguments.delta", + item_id: "fc_1", + output_index: 0, + delta: "{}", + }, + { + type: "response.output_item.done", + output_index: 0, + item: { + type: "function_call", + id: "fc_1", + call_id: "call_1", + name: "todowrite", + arguments: "{}", + status: "completed", + }, + }, + ]) + + expect(out.at(-1)).toMatchObject({ type: "finish", finishReason: { unified: "tool-calls" } }) + }) + + test("treats a text stream without a terminal response chunk as stop", async () => { + const out = await parts([ + { + type: "response.created", + response: { id: "resp_test", created_at: 1, model: "test-model", service_tier: null }, + }, + { + type: "response.output_item.added", + output_index: 0, + item: { type: "message", id: "msg_1" }, + }, + { + type: "response.output_text.delta", + item_id: "msg_1", + delta: "Hello", + logprobs: null, + }, + { + type: "response.output_item.done", + output_index: 0, + item: { type: "message", id: "msg_1" }, + }, + ]) + + expect(out.at(-1)).toMatchObject({ type: "finish", finishReason: { unified: "stop" } }) + }) +}) From 5ee56e29fc98032a5c572b011282b1aeca5ac6b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Mon, 1 Jun 2026 18:04:39 -0300 Subject: [PATCH 2/5] fix(cli): restore worktree family typecheck --- .../opencode/src/kilocode/worktree-family.ts | 4 +- .../worktree-family-submodule.test.ts | 51 ++++++++++--------- 2 files changed, 30 insertions(+), 25 deletions(-) diff --git a/packages/opencode/src/kilocode/worktree-family.ts b/packages/opencode/src/kilocode/worktree-family.ts index 5a55c1f54f5..f95a5e53865 100644 --- a/packages/opencode/src/kilocode/worktree-family.ts +++ b/packages/opencode/src/kilocode/worktree-family.ts @@ -31,10 +31,10 @@ export namespace WorktreeFamily { // In a git submodule, `git worktree list --porcelain` reports the // gitdir (`/.git/modules/`) instead of the actual working // tree, so the parsed list never contains the directory sessions are - // recorded under. Including Instance.worktree keeps submodule sessions + // recorded under. Including ctx.worktree keeps submodule sessions // in scope without affecting normal repos (already present) or linked // worktrees (also already present). - dirs.push(Filesystem.resolve(Instance.worktree)) + dirs.push(Filesystem.resolve(ctx.worktree)) return [...new Set(dirs)] } } diff --git a/packages/opencode/test/kilocode/worktree-family-submodule.test.ts b/packages/opencode/test/kilocode/worktree-family-submodule.test.ts index 42a81eafeb6..2afd7d2327f 100644 --- a/packages/opencode/test/kilocode/worktree-family-submodule.test.ts +++ b/packages/opencode/test/kilocode/worktree-family-submodule.test.ts @@ -1,40 +1,45 @@ import { $ } from "bun" -import { afterEach, describe, expect, test } from "bun:test" +import { afterEach, describe, expect } from "bun:test" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { Effect, Layer } from "effect" import * as fs from "fs/promises" import path from "path" -import { Instance } from "../../src/project/instance" +import { Git } from "../../src/git" import { WorktreeFamily } from "../../src/kilocode/worktree-family" +import { Project } from "../../src/project/project" import * as Log from "@opencode-ai/core/util/log" -import { disposeAllInstances, tmpdir } from "../fixture/fixture" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" Log.init({ print: false }) afterEach(async () => { await disposeAllInstances() + await resetDatabase() }) +const it = testEffect(Layer.mergeAll(Project.defaultLayer, Git.defaultLayer, CrossSpawnSpawner.defaultLayer)) + describe("WorktreeFamily.list — git submodule", () => { - test("returns the submodule's working tree, not its gitdir", async () => { - await using parent = await tmpdir({ git: true }) - await using child = await tmpdir({ git: true }) + it.live("returns the submodule's working tree, not its gitdir", () => + Effect.gen(function* () { + const parent = yield* tmpdirScoped({ git: true }) + const child = yield* tmpdirScoped({ git: true }) - // `protocol.file.allow=always` so the local clone is permitted, then commit - // the .gitmodules entry so the submodule is part of the parent's history. - await $`git -c protocol.file.allow=always submodule add ${child.path} sub`.cwd(parent.path).quiet() - await $`git commit -m "add submodule"`.cwd(parent.path).quiet() + // `protocol.file.allow=always` so the local clone is permitted, then commit + // the .gitmodules entry so the submodule is part of the parent's history. + yield* Effect.promise(() => $`git -c protocol.file.allow=always submodule add ${child} sub`.cwd(parent).quiet()) + yield* Effect.promise(() => $`git commit -m "add submodule"`.cwd(parent).quiet()) - const submodule = path.join(parent.path, "sub") - const submoduleReal = await fs.realpath(submodule) + const submodule = path.join(parent, "sub") + const submoduleReal = yield* Effect.promise(() => fs.realpath(submodule)) - await Instance.provide({ - directory: submodule, - fn: async () => { - const dirs = await WorktreeFamily.list() - // `git worktree list --porcelain` from inside a submodule reports the - // gitdir (`/.git/modules/sub`) as the worktree, so without the - // submodule guard the actual working tree is missing. - expect(dirs).toContain(submoduleReal) - }, - }) - }) + const dirs = yield* provideInstance(submodule)(WorktreeFamily.list()) + // `git worktree list --porcelain` from inside a submodule reports the + // gitdir (`/.git/modules/sub`) as the worktree, so without the + // submodule guard the actual working tree is missing. + expect(dirs).toContain(submoduleReal) + }), + ) }) From c9b1ee0ef0022f9284b9c503ef5fe386424cc5a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Tue, 2 Jun 2026 08:26:47 -0300 Subject: [PATCH 3/5] refactor: improve the session processor --- .changeset/tidy-responses-finish.md | 2 +- .../src/kilocode/session/processor.ts | 43 ++++++++ .../opencode/src/kilocode/session/prompt.ts | 8 ++ .../openai-responses-language-model.ts | 15 --- packages/opencode/src/session/processor.ts | 20 +++- packages/opencode/src/session/prompt.ts | 13 ++- .../provider/openai-responses-finish.test.ts | 104 ------------------ .../test/session/processor-effect.test.ts | 47 ++++++++ packages/opencode/test/session/prompt.test.ts | 28 +++++ 9 files changed, 157 insertions(+), 123 deletions(-) delete mode 100644 packages/opencode/test/kilocode/provider/openai-responses-finish.test.ts diff --git a/.changeset/tidy-responses-finish.md b/.changeset/tidy-responses-finish.md index 6f569648c52..c47aa3279ac 100644 --- a/.changeset/tidy-responses-finish.md +++ b/.changeset/tidy-responses-finish.md @@ -2,4 +2,4 @@ "@kilocode/cli": patch --- -Avoid showing unexpected incomplete-response warnings when Responses streams finish after a completed tool call without a terminal event. +Treat empty provider responses as retryable failures and avoid sending oversized prompt payloads that remain too large after pruning. diff --git a/packages/opencode/src/kilocode/session/processor.ts b/packages/opencode/src/kilocode/session/processor.ts index a8a1a7174ff..c044c34126b 100644 --- a/packages/opencode/src/kilocode/session/processor.ts +++ b/packages/opencode/src/kilocode/session/processor.ts @@ -24,6 +24,26 @@ export namespace KiloSessionProcessor { "The model hit its output limit while reasoning and produced no actionable output. Try disabling reasoning or increasing the output limit." export const PROVIDER_FINISH_ERROR_MESSAGE = "The provider ended the response with an error before returning details. Start a new message to retry; Kilo will compact the oversized conversation first if needed." + export const EMPTY_RESPONSE_MESSAGE = + "The provider returned an empty response without a finish reason. Kilo will retry the request." + + function tokenTotal(tokens: MessageV2.Assistant["tokens"]) { + return ( + (tokens.total ?? 0) + + tokens.input + + tokens.output + + tokens.reasoning + + tokens.cache.read + + tokens.cache.write + ) + } + + function output(part: MessageV2.Part) { + if (part.type === "tool") return true + if (part.type === "text") return part.text.trim() !== "" + if (part.type === "reasoning") return part.text.trim() !== "" + return false + } export function reviewTelemetry(command: string | undefined): ReviewTelemetry | undefined { if (!isReviewCommand(command)) return @@ -194,6 +214,29 @@ export namespace KiloSessionProcessor { } } + export function emptyResponseError(input: { + msg: MessageV2.Assistant + finish: string + tokens: MessageV2.Assistant["tokens"] + cost: number + parts: MessageV2.Part[] + step: { reasoning: boolean; text: boolean; tool: boolean } + }) { + if (input.finish !== "other") return + if (input.msg.error) return + if (input.cost !== 0) return + if (tokenTotal(input.tokens) !== 0) return + if (input.step.reasoning || input.step.text || input.step.tool) return + if (input.parts.some(output)) return + + log.warn("empty provider response", { messageID: input.msg.id }) + return new MessageV2.APIError({ message: EMPTY_RESPONSE_MESSAGE, isRetryable: true }).toObject() + } + + export function preserveError(error: unknown): MessageV2.Assistant["error"] | undefined { + if (MessageV2.APIError.isInstance(error)) return { name: "APIError", data: error.data } + } + export function lengthWarning(input: { msg: MessageV2.Assistant step: { reasoning: boolean; text: boolean; tool: boolean } diff --git a/packages/opencode/src/kilocode/session/prompt.ts b/packages/opencode/src/kilocode/session/prompt.ts index 49803420791..c9f1c4aa32c 100644 --- a/packages/opencode/src/kilocode/session/prompt.ts +++ b/packages/opencode/src/kilocode/session/prompt.ts @@ -20,6 +20,8 @@ import CODE_SWITCH from "@/session/prompt/code-switch.txt" export namespace KiloSessionPrompt { const modes = ["ask", "plan"] + export const PAYLOAD_OVERFLOW_MESSAGE = + "The conversation is still too large to send after pruning old tool output. Start a new message to retry after compaction." /** * Determines whether the plan follow-up prompt should be shown. @@ -115,6 +117,12 @@ export namespace KiloSessionPrompt { ) } + export function payloadOverflowError(input: { size: number; limit: number }) { + return new MessageV2.ContextOverflowError({ + message: `${PAYLOAD_OVERFLOW_MESSAGE} Payload size: ${input.size} bytes; limit: ${input.limit} bytes.`, + }).toObject() + } + export function hardPermissions(input: { agent: { name: string; permission: Permission.Ruleset } }) { if (!modes.includes(input.agent.name)) return return input.agent.permission diff --git a/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts b/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts index 1a7f960e53e..250d1f6f340 100644 --- a/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts +++ b/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts @@ -803,9 +803,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { unified: "other", raw: undefined, } - // kilocode_change start - avoid surfacing "other" when a stream omits the terminal response chunk - let hasResponseFinished = false - // kilocode_change end const usage: { inputTokens: number | undefined outputTokens: number | undefined @@ -1269,9 +1266,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { }), raw: value.response.incomplete_details?.reason ?? undefined, } - // kilocode_change start - hasResponseFinished = true - // kilocode_change end usage.inputTokens = value.response.usage.input_tokens usage.outputTokens = value.response.usage.output_tokens usage.totalTokens = value.response.usage.input_tokens + value.response.usage.output_tokens @@ -1325,15 +1319,6 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { providerMetadata.openai.serviceTier = serviceTier } - // kilocode_change start - classify missing terminal chunks by observed output instead of "other" - if (!hasResponseFinished && finishReason.unified === "other") { - finishReason = { - unified: mapOpenAIResponseFinishReason({ finishReason: undefined, hasFunctionCall }), - raw: undefined, - } - } - // kilocode_change end - controller.enqueue({ type: "finish", finishReason, diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 31808d9f164..7e01229dbfc 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -149,11 +149,16 @@ export const layer: Layer.Layer< const ac = new AbortController() // kilocode_change — abort controller for offline handler const slog = log.clone().tag("session.id", input.sessionID).tag("messageID", input.assistantMessage.id) - const parse = (e: unknown) => - MessageV2.fromError(e, { + const parse = (e: unknown) => { + // kilocode_change start - preserve retryable errors raised by Kilo processor guards + const err = KiloSessionProcessor.preserveError(e) + if (err) return err + // kilocode_change end + return MessageV2.fromError(e, { providerID: input.model.providerID, aborted, }) + } const settleToolCall = Effect.fn("SessionProcessor.settleToolCall")(function* (toolCallID: string) { const done = ctx.toolcalls[toolCallID]?.done @@ -551,6 +556,17 @@ export const layer: Layer.Layer< usage: value.usage, metadata: value.providerMetadata, }) + // kilocode_change start - retry empty provider streams instead of accepting finish "other" as completion + const empty = KiloSessionProcessor.emptyResponseError({ + msg: ctx.assistantMessage, + finish: value.finishReason, + tokens: usage.tokens, + cost: usage.cost, + parts: MessageV2.parts(ctx.assistantMessage.id), + step: ctx.step, + }) + if (empty) return yield* Effect.fail(empty) + // kilocode_change end // kilocode_change start - guard against finish-step without start-step: // ctx.stepStart is 0 until `start-step` fires, which would feed a // huge bogus `elapsed` into telemetry. Fall back to now(). diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index bc930f6563f..155ff17fabe 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1732,7 +1732,18 @@ NOTE: At any point in time through this workflow you should feel free to ask the msgs = KiloSessionPrompt.maybeStripHistoricalMedia(msgs) modelMsgs = yield* MessageV2.toModelMessagesEffect(msgs, model) const nextSize = Buffer.byteLength(JSON.stringify(modelMsgs)) - if (nextSize > REQUEST_PRUNE_BYTES) log.warn("payload still large after pruning", { size: nextSize }) + if (nextSize > REQUEST_PRUNE_BYTES) { + log.warn("payload still large after pruning", { size: nextSize }) + handle.message.error = KiloSessionPrompt.payloadOverflowError({ + size: nextSize, + limit: REQUEST_PRUNE_BYTES, + }) + yield* sessions.updateMessage(handle.message) + yield* bus.publish(Session.Event.Error, { sessionID, error: handle.message.error }) + yield* status.set(sessionID, { type: "idle" }) + closeReasons.set(sessionID, "error") + return "break" as const + } } // kilocode_change end const system = [...env, ...instructions, ...(skills ? [skills] : [])] diff --git a/packages/opencode/test/kilocode/provider/openai-responses-finish.test.ts b/packages/opencode/test/kilocode/provider/openai-responses-finish.test.ts deleted file mode 100644 index fcd7bcfdd14..00000000000 --- a/packages/opencode/test/kilocode/provider/openai-responses-finish.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, expect, test } from "bun:test" -import type { LanguageModelV3Prompt, LanguageModelV3StreamPart } from "@ai-sdk/provider" -import { OpenAIResponsesLanguageModel } from "../../../src/provider/sdk/copilot/responses/openai-responses-language-model" - -const prompt: LanguageModelV3Prompt = [{ role: "user", content: [{ type: "text", text: "Hello" }] }] - -function stream(chunks: unknown[]) { - return new ReadableStream({ - start(controller) { - for (const chunk of chunks) { - controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify(chunk)}\n\n`)) - } - controller.close() - }, - }) -} - -function model(chunks: unknown[]) { - const fetch: typeof globalThis.fetch = Object.assign( - async () => - new Response(stream(chunks), { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }), - { preconnect: () => {} }, - ) - - return new OpenAIResponsesLanguageModel("test-model", { - provider: "test", - url: () => "https://api.test.com/responses", - headers: () => ({}), - fetch, - }) -} - -async function parts(chunks: unknown[]) { - const result = await model(chunks).doStream({ prompt, includeRawChunks: false }) - const out: LanguageModelV3StreamPart[] = [] - for await (const part of result.stream) out.push(part) - return out -} - -describe("OpenAI Responses finish mapping", () => { - test("treats a completed tool call without a terminal response chunk as tool-calls", async () => { - const out = await parts([ - { - type: "response.created", - response: { id: "resp_test", created_at: 1, model: "test-model", service_tier: null }, - }, - { - type: "response.output_item.added", - output_index: 0, - item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "todowrite", arguments: "" }, - }, - { - type: "response.function_call_arguments.delta", - item_id: "fc_1", - output_index: 0, - delta: "{}", - }, - { - type: "response.output_item.done", - output_index: 0, - item: { - type: "function_call", - id: "fc_1", - call_id: "call_1", - name: "todowrite", - arguments: "{}", - status: "completed", - }, - }, - ]) - - expect(out.at(-1)).toMatchObject({ type: "finish", finishReason: { unified: "tool-calls" } }) - }) - - test("treats a text stream without a terminal response chunk as stop", async () => { - const out = await parts([ - { - type: "response.created", - response: { id: "resp_test", created_at: 1, model: "test-model", service_tier: null }, - }, - { - type: "response.output_item.added", - output_index: 0, - item: { type: "message", id: "msg_1" }, - }, - { - type: "response.output_text.delta", - item_id: "msg_1", - delta: "Hello", - logprobs: null, - }, - { - type: "response.output_item.done", - output_index: 0, - item: { type: "message", id: "msg_1" }, - }, - ]) - - expect(out.at(-1)).toMatchObject({ type: "finish", finishReason: { unified: "stop" } }) - }) -}) diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 308f7c859b7..8efd323c4e2 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -466,6 +466,53 @@ it.live("session.processor effect tests reset reasoning state across retries", ( ), ) +it.live("session.processor effect tests retry empty other finishes", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const { processors, session, provider } = yield* boot() + + yield* llm.push(reply().usage({ input: 0, output: 0 }).finish("other"), reply().text("after").stop()) + + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "empty other") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + + const value = yield* handle.process({ + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + } satisfies MessageV2.User, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "empty other" }], + tools: {}, + }) + + const parts = MessageV2.parts(msg.id) + + expect(value).toBe("continue") + expect(yield* llm.calls).toBe(2) + expect(handle.message.error).toBeUndefined() + expect(parts.some((part) => part.type === "text" && part.text === "after")).toBe(true) + expect(parts.some((part) => part.type === "step-finish" && part.reason === "other")).toBe(false) + }), + { git: true, config: (url) => providerCfg(url) }, + ), +) + it.live("session.processor effect tests do not retry unknown json errors", () => provideTmpdirServer( ({ dir, llm }) => diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index fd6655dd545..df8ee984e66 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -397,6 +397,34 @@ it.live("loop calls LLM and returns assistant message", () => ), ) +it.live("loop does not send payload that remains large after pruning", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Large payload", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "x".repeat(1_300_000) }], + }) + + const result = yield* prompt.loop({ sessionID: chat.id }) + + expect(yield* llm.calls).toBe(0) + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") { + expect(result.info.error?.name).toBe("ContextOverflowError") + } + }), + { git: true, config: providerCfg }, + ), +) + // kilocode_change start - replacement prompts unblock pending Question service requests it.live("new prompt dismisses a pending question", () => provideTmpdirServer( From 5dd3dc72addc0a42fab36a152840a6e2fdbc1222 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Tue, 2 Jun 2026 08:57:37 -0300 Subject: [PATCH 4/5] chore(cli): annotate session regression changes --- packages/opencode/src/session/processor.ts | 12 +++++------- .../opencode/test/session/processor-effect.test.ts | 2 ++ packages/opencode/test/session/prompt.test.ts | 2 ++ 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 671b7517218..a523e448106 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -149,16 +149,14 @@ export const layer: Layer.Layer< const ac = new AbortController() // kilocode_change — abort controller for offline handler const slog = log.clone().tag("session.id", input.sessionID).tag("messageID", input.assistantMessage.id) - const parse = (e: unknown) => { - // kilocode_change start - preserve retryable errors raised by Kilo processor guards - const err = KiloSessionProcessor.preserveError(e) - if (err) return err - // kilocode_change end - return MessageV2.fromError(e, { + // kilocode_change start - preserve retryable errors raised by Kilo processor guards + const parse = (e: unknown) => + KiloSessionProcessor.preserveError(e) ?? + MessageV2.fromError(e, { providerID: input.model.providerID, aborted, }) - } + // kilocode_change end const settleToolCall = Effect.fn("SessionProcessor.settleToolCall")(function* (toolCallID: string) { const done = ctx.toolcalls[toolCallID]?.done diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 8efd323c4e2..71f93a5ea2b 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -466,6 +466,7 @@ it.live("session.processor effect tests reset reasoning state across retries", ( ), ) +// kilocode_change start - retry empty provider responses it.live("session.processor effect tests retry empty other finishes", () => provideTmpdirServer( ({ dir, llm }) => @@ -512,6 +513,7 @@ it.live("session.processor effect tests retry empty other finishes", () => { git: true, config: (url) => providerCfg(url) }, ), ) +// kilocode_change end it.live("session.processor effect tests do not retry unknown json errors", () => provideTmpdirServer( diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index dcbb7e655be..9a0d8c50dbf 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -397,6 +397,7 @@ it.live("loop calls LLM and returns assistant message", () => ), ) +// kilocode_change start - reject oversized payloads after pruning it.live("loop does not send payload that remains large after pruning", () => provideTmpdirServer( Effect.fnUntraced(function* ({ llm }) { @@ -424,6 +425,7 @@ it.live("loop does not send payload that remains large after pruning", () => { git: true, config: providerCfg }, ), ) +// kilocode_change end // kilocode_change start - replacement prompts unblock pending Question service requests it.live("new prompt dismisses a pending question", () => From 60f4c052a3c4011685953f451a43297d39210b5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Tue, 2 Jun 2026 09:35:02 -0300 Subject: [PATCH 5/5] refactor: isolate the changes inside of the kilo folders --- .../src/kilocode/session/processor.ts | 22 +- .../opencode/src/kilocode/session/prompt.ts | 24 +- packages/opencode/src/session/processor.ts | 21 +- packages/opencode/src/session/prompt.ts | 13 +- .../kilocode/session/processor-effect.test.ts | 220 ++++++++++++++++ .../test/kilocode/session/prompt.test.ts | 236 ++++++++++++++++++ .../test/session/processor-effect.test.ts | 49 ---- packages/opencode/test/session/prompt.test.ts | 30 --- 8 files changed, 502 insertions(+), 113 deletions(-) create mode 100644 packages/opencode/test/kilocode/session/processor-effect.test.ts create mode 100644 packages/opencode/test/kilocode/session/prompt.test.ts diff --git a/packages/opencode/src/kilocode/session/processor.ts b/packages/opencode/src/kilocode/session/processor.ts index ccab0540515..67d1e432b48 100644 --- a/packages/opencode/src/kilocode/session/processor.ts +++ b/packages/opencode/src/kilocode/session/processor.ts @@ -1,6 +1,6 @@ -// kilocode_change - new file import { Telemetry, type ReviewCommand } from "@kilocode/kilo-telemetry" import { SessionNetwork } from "@/session/network" +import type { ProviderID } from "@/provider/schema" import type { SessionID } from "@/session/schema" import type { SessionStatus } from "@/session/status" import { MessageV2 } from "@/session/message-v2" @@ -234,10 +234,28 @@ export namespace KiloSessionProcessor { return new MessageV2.APIError({ message: EMPTY_RESPONSE_MESSAGE, isRetryable: true }).toObject() } - export function preserveError(error: unknown): MessageV2.Assistant["error"] | undefined { + export function guardEmptyResponse(input: Parameters[0]) { + return Effect.gen(function* () { + const err = emptyResponseError(input) + if (!err) return + return yield* Effect.fail(err) + }) + } + + function preserveError(error: unknown): MessageV2.Assistant["error"] | undefined { if (MessageV2.APIError.isInstance(error)) return { name: "APIError", data: error.data } } + export function parse(error: unknown, input: { providerID: ProviderID; aborted: boolean }) { + return ( + preserveError(error) ?? + MessageV2.fromError(error, { + providerID: input.providerID, + aborted: input.aborted, + }) + ) + } + export function lengthWarning(input: { msg: MessageV2.Assistant step: { reasoning: boolean; text: boolean; tool: boolean } diff --git a/packages/opencode/src/kilocode/session/prompt.ts b/packages/opencode/src/kilocode/session/prompt.ts index 5f260491ff1..56823dc6b03 100644 --- a/packages/opencode/src/kilocode/session/prompt.ts +++ b/packages/opencode/src/kilocode/session/prompt.ts @@ -1,8 +1,8 @@ -// kilocode_change - new file import path from "path" import fs from "fs/promises" import { StringDecoder } from "string_decoder" import { Cause, Effect, Exit } from "effect" +import { Bus } from "@/bus" import { SessionID, PartID } from "@/session/schema" import { MessageV2 } from "@/session/message-v2" import { Session } from "@/session/session" @@ -18,8 +18,10 @@ import { Identifier } from "@/id/id" import { Filesystem } from "@/util/filesystem" import PROMPT_PLAN from "@/session/prompt/plan.txt" import CODE_SWITCH from "@/session/prompt/code-switch.txt" +import * as Log from "@opencode-ai/core/util/log" export namespace KiloSessionPrompt { + const log = Log.create({ service: "session.prompt.kilo" }) const modes = ["ask", "plan"] export const PAYLOAD_OVERFLOW_MESSAGE = "The conversation is still too large to send after pruning old tool output. Start a new message to retry after compaction." @@ -124,6 +126,26 @@ export namespace KiloSessionPrompt { }).toObject() } + export const rejectPayloadOverflow = Effect.fn("KiloSessionPrompt.rejectPayloadOverflow")(function* (input: { + sessionID: SessionID + msg: MessageV2.Assistant + size: number + limit: number + sessions: Pick + bus: Pick + status: Pick + close: Map + }) { + if (input.size <= input.limit) return false + log.warn("payload still large after pruning", { size: input.size }) + input.msg.error = payloadOverflowError({ size: input.size, limit: input.limit }) + yield* input.sessions.updateMessage(input.msg) + yield* input.bus.publish(Session.Event.Error, { sessionID: input.sessionID, error: input.msg.error }) + yield* input.status.set(input.sessionID, { type: "idle" }) + input.close.set(input.sessionID, "error") + return true + }) + export function hardPermissions(input: { agent: { name: string; permission: Permission.Ruleset } }) { if (!modes.includes(input.agent.name)) return return input.agent.permission diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index a523e448106..20d1ac3b6d5 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -149,14 +149,7 @@ export const layer: Layer.Layer< const ac = new AbortController() // kilocode_change — abort controller for offline handler const slog = log.clone().tag("session.id", input.sessionID).tag("messageID", input.assistantMessage.id) - // kilocode_change start - preserve retryable errors raised by Kilo processor guards - const parse = (e: unknown) => - KiloSessionProcessor.preserveError(e) ?? - MessageV2.fromError(e, { - providerID: input.model.providerID, - aborted, - }) - // kilocode_change end + const parse = (e: unknown) => KiloSessionProcessor.parse(e, { providerID: input.model.providerID, aborted }) // kilocode_change - preserve retryable errors raised by Kilo processor guards const settleToolCall = Effect.fn("SessionProcessor.settleToolCall")(function* (toolCallID: string) { const done = ctx.toolcalls[toolCallID]?.done @@ -554,17 +547,7 @@ export const layer: Layer.Layer< usage: value.usage, metadata: value.providerMetadata, }) - // kilocode_change start - retry empty provider streams instead of accepting finish "other" as completion - const empty = KiloSessionProcessor.emptyResponseError({ - msg: ctx.assistantMessage, - finish: value.finishReason, - tokens: usage.tokens, - cost: usage.cost, - parts: MessageV2.parts(ctx.assistantMessage.id), - step: ctx.step, - }) - if (empty) return yield* Effect.fail(empty) - // kilocode_change end + yield* KiloSessionProcessor.guardEmptyResponse({ msg: ctx.assistantMessage, finish: value.finishReason, tokens: usage.tokens, cost: usage.cost, parts: MessageV2.parts(ctx.assistantMessage.id), step: ctx.step }) // kilocode_change - retry empty provider streams instead of accepting finish "other" as completion // kilocode_change start - guard against finish-step without start-step: // ctx.stepStart is 0 until `start-step` fires, which would feed a // huge bogus `elapsed` into telemetry. Fall back to now(). diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 386a1bd4d9d..1816f8a079d 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1733,18 +1733,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the msgs = KiloSessionPrompt.maybeStripHistoricalMedia(msgs) modelMsgs = yield* MessageV2.toModelMessagesEffect(msgs, model) const nextSize = Buffer.byteLength(JSON.stringify(modelMsgs)) - if (nextSize > REQUEST_PRUNE_BYTES) { - log.warn("payload still large after pruning", { size: nextSize }) - handle.message.error = KiloSessionPrompt.payloadOverflowError({ - size: nextSize, - limit: REQUEST_PRUNE_BYTES, - }) - yield* sessions.updateMessage(handle.message) - yield* bus.publish(Session.Event.Error, { sessionID, error: handle.message.error }) - yield* status.set(sessionID, { type: "idle" }) - closeReasons.set(sessionID, "error") - return "break" as const - } + if (yield* KiloSessionPrompt.rejectPayloadOverflow({ sessionID, msg: handle.message, size: nextSize, limit: REQUEST_PRUNE_BYTES, sessions, bus, status, close: closeReasons })) return "break" as const // kilocode_change - reject oversized payloads after pruning } // kilocode_change end const system = [...env, ...instructions, ...(skills ? [skills] : [])] diff --git a/packages/opencode/test/kilocode/session/processor-effect.test.ts b/packages/opencode/test/kilocode/session/processor-effect.test.ts new file mode 100644 index 00000000000..745792f9b83 --- /dev/null +++ b/packages/opencode/test/kilocode/session/processor-effect.test.ts @@ -0,0 +1,220 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { expect } from "bun:test" +import { Effect, Layer } from "effect" +import path from "path" +import type { Agent } from "@/agent/agent" +import { Agent as AgentSvc } from "@/agent/agent" +import { Bus } from "@/bus" +import { Config } from "@/config/config" +import { Permission } from "@/permission" +import { Plugin } from "@/plugin" +import { Provider } from "@/provider/provider" +import { ModelID, ProviderID } from "@/provider/schema" +import { Session } from "@/session/session" +import { LLM } from "@/session/llm" +import { MessageV2 } from "@/session/message-v2" +import { SessionProcessor } from "@/session/processor" +import { MessageID, PartID, SessionID } from "@/session/schema" +import { SessionStatus } from "@/session/status" +import { SessionSummary } from "@/session/summary" +import { Snapshot } from "@/snapshot" +import * as Log from "@opencode-ai/core/util/log" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { provideTmpdirServer } from "../../fixture/fixture" +import { testEffect } from "../../lib/effect" +import { reply, TestLLMServer } from "../../lib/llm-server" + +void Log.init({ print: false }) + +const ref = { + providerID: ProviderID.make("test"), + modelID: ModelID.make("test-model"), +} + +const cfg = { + provider: { + test: { + name: "Test", + id: "test", + env: [], + npm: "@ai-sdk/openai-compatible", + models: { + "test-model": { + id: "test-model", + name: "Test Model", + attachment: false, + reasoning: false, + temperature: false, + tool_call: true, + release_date: "2025-01-01", + limit: { context: 100000, output: 10000 }, + cost: { input: 0, output: 0 }, + options: {}, + }, + }, + options: { + apiKey: "test-key", + baseURL: "http://localhost:1/v1", + }, + }, + }, +} + +function providerCfg(url: string) { + return { + ...cfg, + provider: { + ...cfg.provider, + test: { + ...cfg.provider.test, + options: { + ...cfg.provider.test.options, + baseURL: url, + }, + }, + }, + } +} + +function agent(): Agent.Info { + return { + name: "build", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } +} + +const user = Effect.fn("KiloProcessorTest.user")(function* (sessionID: SessionID, text: string) { + const sessions = yield* Session.Service + const msg = yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + agent: "build", + model: ref, + time: { created: Date.now() }, + }) + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID, + type: "text", + text, + }) + return msg +}) + +const assistant = Effect.fn("KiloProcessorTest.assistant")(function* ( + sessionID: SessionID, + parentID: MessageID, + root: string, +) { + const sessions = yield* Session.Service + const msg: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + sessionID, + mode: "build", + agent: "build", + path: { cwd: root, root }, + cost: 0, + tokens: { + total: 0, + input: 0, + output: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: ref.modelID, + providerID: ref.providerID, + parentID, + time: { created: Date.now() }, + finish: "end_turn", + } + yield* sessions.updateMessage(msg) + return msg +}) + +const summary = Layer.succeed( + SessionSummary.Service, + SessionSummary.Service.of({ + summarize: () => Effect.void, + diff: () => Effect.succeed([]), + computeDiff: () => Effect.succeed([]), + }), +) + +const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) +const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) +const deps = Layer.mergeAll( + Session.defaultLayer, + Snapshot.defaultLayer, + AgentSvc.defaultLayer, + Permission.defaultLayer, + Plugin.defaultLayer, + Config.defaultLayer, + LLM.defaultLayer, + Provider.defaultLayer, + status, +).pipe(Layer.provideMerge(infra)) +const env = Layer.mergeAll( + TestLLMServer.layer, + SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps)), +) + +const it = testEffect(env) + +const boot = Effect.fn("KiloProcessorTest.boot")(function* () { + const processors = yield* SessionProcessor.Service + const sessions = yield* Session.Service + const provider = yield* Provider.Service + return { processors, sessions, provider } +}) + +it.live("retries empty other provider finishes", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const { processors, sessions, provider } = yield* boot() + + yield* llm.push(reply().usage({ input: 0, output: 0 }).finish("other"), reply().text("after").stop()) + + const chat = yield* sessions.create({}) + const parent = yield* user(chat.id, "empty other") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + + const value = yield* handle.process({ + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + } satisfies MessageV2.User, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "empty other" }], + tools: {}, + }) + + const parts = MessageV2.parts(msg.id) + + expect(value).toBe("continue") + expect(yield* llm.calls).toBe(2) + expect(handle.message.error).toBeUndefined() + expect(parts.some((part) => part.type === "text" && part.text === "after")).toBe(true) + expect(parts.some((part) => part.type === "step-finish" && part.reason === "other")).toBe(false) + }), + { git: true, config: (url) => providerCfg(url) }, + ), +) diff --git a/packages/opencode/test/kilocode/session/prompt.test.ts b/packages/opencode/test/kilocode/session/prompt.test.ts new file mode 100644 index 00000000000..7e6a05467aa --- /dev/null +++ b/packages/opencode/test/kilocode/session/prompt.test.ts @@ -0,0 +1,236 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { FetchHttpClient } from "effect/unstable/http" +import { expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Agent as AgentSvc } from "@/agent/agent" +import { Bus } from "@/bus" +import { Command } from "@/command" +import { Config } from "@/config/config" +import { Env } from "@/env" +import { Ripgrep } from "@/file/ripgrep" +import { Format } from "@/format" +import { Git } from "@/git" +import { LSP } from "@/lsp/lsp" +import { MCP } from "@/mcp" +import { Permission } from "@/permission" +import { Plugin } from "@/plugin" +import { Provider as ProviderSvc } from "@/provider/provider" +import { ModelID, ProviderID } from "@/provider/schema" +import { Question } from "@/question" +import { Session } from "@/session/session" +import { SessionCompaction } from "@/session/compaction" +import { Instruction } from "@/session/instruction" +import { LLM } from "@/session/llm" +import { SessionProcessor } from "@/session/processor" +import { SessionPrompt } from "@/session/prompt" +import { SessionRevert } from "@/session/revert" +import { SessionRunState } from "@/session/run-state" +import { SessionStatus } from "@/session/status" +import { SystemPrompt } from "@/session/system" +import { SessionSummary } from "@/session/summary" +import { Todo } from "@/session/todo" +import { Skill } from "@/skill" +import { Snapshot } from "@/snapshot" +import { ToolRegistry } from "@/tool/registry" +import { Truncate } from "@/tool/truncate" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import * as Log from "@opencode-ai/core/util/log" +import { provideTmpdirServer } from "../../fixture/fixture" +import { testEffect } from "../../lib/effect" +import { TestLLMServer } from "../../lib/llm-server" + +void Log.init({ print: false }) + +const ref = { + providerID: ProviderID.make("test"), + modelID: ModelID.make("test-model"), +} + +const summary = Layer.succeed( + SessionSummary.Service, + SessionSummary.Service.of({ + summarize: () => Effect.void, + diff: () => Effect.succeed([]), + computeDiff: () => Effect.succeed([]), + }), +) + +const plugin = Layer.mock(Plugin.Service)({ + trigger: (_name: Name, _input: Input, output: Output) => Effect.succeed(output), + list: () => Effect.succeed([]), + init: () => Effect.void, +}) + +const mcp = Layer.succeed( + MCP.Service, + MCP.Service.of({ + status: () => Effect.succeed({}), + clients: () => Effect.succeed({}), + tools: () => Effect.succeed({}), + prompts: () => Effect.succeed({}), + resources: () => Effect.succeed({}), + add: () => Effect.succeed({ status: { status: "disabled" as const } }), + connect: () => Effect.void, + disconnect: () => Effect.void, + getPrompt: () => Effect.succeed(undefined), + readResource: () => Effect.succeed(undefined), + startAuth: () => Effect.die("unexpected MCP auth in Kilo prompt tests"), + authenticate: () => Effect.die("unexpected MCP auth in Kilo prompt tests"), + finishAuth: () => Effect.die("unexpected MCP auth in Kilo prompt tests"), + removeAuth: () => Effect.void, + supportsOAuth: () => Effect.succeed(false), + hasStoredTokens: () => Effect.succeed(false), + getAuthStatus: () => Effect.succeed("not_authenticated" as const), + }), +) + +const lsp = Layer.succeed( + LSP.Service, + LSP.Service.of({ + init: () => Effect.void, + status: () => Effect.succeed([]), + hasClients: () => Effect.succeed(false), + touchFile: () => Effect.void, + diagnostics: () => Effect.succeed({}), + hover: () => Effect.succeed(undefined), + definition: () => Effect.succeed([]), + references: () => Effect.succeed([]), + implementation: () => Effect.succeed([]), + documentSymbol: () => Effect.succeed([]), + workspaceSymbol: () => Effect.succeed([]), + prepareCallHierarchy: () => Effect.succeed([]), + incomingCalls: () => Effect.succeed([]), + outgoingCalls: () => Effect.succeed([]), + }), +) + +const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) +const run = SessionRunState.layer.pipe(Layer.provide(status)) +const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) + +function makeHttp() { + const deps = Layer.mergeAll( + Session.defaultLayer, + Snapshot.defaultLayer, + LLM.defaultLayer, + Env.defaultLayer, + AgentSvc.defaultLayer, + Command.defaultLayer, + Permission.defaultLayer, + plugin, + Config.defaultLayer, + ProviderSvc.defaultLayer, + lsp, + mcp, + AppFileSystem.defaultLayer, + status, + ).pipe(Layer.provideMerge(infra)) + const question = Question.layer.pipe(Layer.provideMerge(deps)) + const todo = Todo.layer.pipe(Layer.provideMerge(deps)) + const registry = ToolRegistry.layer.pipe( + Layer.provide(Skill.defaultLayer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(Ripgrep.defaultLayer), + Layer.provide(Format.defaultLayer), + Layer.provide(Git.defaultLayer), + Layer.provideMerge(todo), + Layer.provideMerge(question), + Layer.provideMerge(deps), + ) + const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) + const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps)) + const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) + return Layer.mergeAll( + TestLLMServer.layer, + SessionPrompt.layer.pipe( + Layer.provide(SessionRevert.defaultLayer), + Layer.provide(summary), + Layer.provideMerge(run), + Layer.provideMerge(compact), + Layer.provideMerge(proc), + Layer.provideMerge(registry), + Layer.provideMerge(trunc), + Layer.provideMerge(question), + Layer.provide(Instruction.defaultLayer), + Layer.provide(SystemPrompt.defaultLayer), + Layer.provideMerge(deps), + ), + ).pipe(Layer.provide(summary)) +} + +const it = testEffect(makeHttp()) + +const cfg = { + provider: { + test: { + name: "Test", + id: "test", + env: [], + npm: "@ai-sdk/openai-compatible", + models: { + "test-model": { + id: "test-model", + name: "Test Model", + attachment: false, + reasoning: false, + temperature: false, + tool_call: true, + release_date: "2025-01-01", + limit: { context: 100000, output: 10000 }, + cost: { input: 0, output: 0 }, + options: {}, + }, + }, + options: { + apiKey: "test-key", + baseURL: "http://localhost:1/v1", + }, + }, + }, +} + +function providerCfg(url: string) { + return { + ...cfg, + provider: { + ...cfg.provider, + test: { + ...cfg.provider.test, + options: { + ...cfg.provider.test.options, + baseURL: url, + }, + }, + }, + } +} + +it.live("does not send payload that remains large after pruning", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Large payload", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "x".repeat(1_300_000) }], + }) + + const result = yield* prompt.loop({ sessionID: chat.id }) + + expect(yield* llm.calls).toBe(0) + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") { + expect(result.info.error?.name).toBe("ContextOverflowError") + } + }), + { git: true, config: providerCfg }, + ), +) diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 71f93a5ea2b..308f7c859b7 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -466,55 +466,6 @@ it.live("session.processor effect tests reset reasoning state across retries", ( ), ) -// kilocode_change start - retry empty provider responses -it.live("session.processor effect tests retry empty other finishes", () => - provideTmpdirServer( - ({ dir, llm }) => - Effect.gen(function* () { - const { processors, session, provider } = yield* boot() - - yield* llm.push(reply().usage({ input: 0, output: 0 }).finish("other"), reply().text("after").stop()) - - const chat = yield* session.create({}) - const parent = yield* user(chat.id, "empty other") - const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) - const mdl = yield* provider.getModel(ref.providerID, ref.modelID) - const handle = yield* processors.create({ - assistantMessage: msg, - sessionID: chat.id, - model: mdl, - }) - - const value = yield* handle.process({ - user: { - id: parent.id, - sessionID: chat.id, - role: "user", - time: parent.time, - agent: parent.agent, - model: { providerID: ref.providerID, modelID: ref.modelID }, - } satisfies MessageV2.User, - sessionID: chat.id, - model: mdl, - agent: agent(), - system: [], - messages: [{ role: "user", content: "empty other" }], - tools: {}, - }) - - const parts = MessageV2.parts(msg.id) - - expect(value).toBe("continue") - expect(yield* llm.calls).toBe(2) - expect(handle.message.error).toBeUndefined() - expect(parts.some((part) => part.type === "text" && part.text === "after")).toBe(true) - expect(parts.some((part) => part.type === "step-finish" && part.reason === "other")).toBe(false) - }), - { git: true, config: (url) => providerCfg(url) }, - ), -) -// kilocode_change end - it.live("session.processor effect tests do not retry unknown json errors", () => provideTmpdirServer( ({ dir, llm }) => diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 9a0d8c50dbf..e4514032810 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -397,36 +397,6 @@ it.live("loop calls LLM and returns assistant message", () => ), ) -// kilocode_change start - reject oversized payloads after pruning -it.live("loop does not send payload that remains large after pruning", () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ - title: "Large payload", - permission: [{ permission: "*", pattern: "*", action: "allow" }], - }) - yield* prompt.prompt({ - sessionID: chat.id, - agent: "build", - noReply: true, - parts: [{ type: "text", text: "x".repeat(1_300_000) }], - }) - - const result = yield* prompt.loop({ sessionID: chat.id }) - - expect(yield* llm.calls).toBe(0) - expect(result.info.role).toBe("assistant") - if (result.info.role === "assistant") { - expect(result.info.error?.name).toBe("ContextOverflowError") - } - }), - { git: true, config: providerCfg }, - ), -) -// kilocode_change end - // kilocode_change start - replacement prompts unblock pending Question service requests it.live("new prompt dismisses a pending question", () => provideTmpdirServer(