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..c47aa3279ac --- /dev/null +++ b/.changeset/tidy-responses-finish.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Treat empty provider responses as retryable failures and avoid sending oversized prompt payloads that remain too large after pruning. 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/kilocode/session/processor.ts b/packages/opencode/src/kilocode/session/processor.ts index dde0a25d57b..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" @@ -25,6 +25,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 @@ -195,6 +215,47 @@ 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 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 7da8a541d76..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,9 +18,13 @@ 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." /** * Determines whether the plan follow-up prompt should be shown. @@ -116,6 +120,32 @@ 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 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/kilocode/worktree-family.ts b/packages/opencode/src/kilocode/worktree-family.ts index b4ae5f3003c..fcc3dfe96fb 100644 --- a/packages/opencode/src/kilocode/worktree-family.ts +++ b/packages/opencode/src/kilocode/worktree-family.ts @@ -30,7 +30,7 @@ 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 the context 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(ctx.worktree)) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 8abcd00e189..20d1ac3b6d5 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -149,11 +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) - const parse = (e: unknown) => - MessageV2.fromError(e, { - providerID: input.model.providerID, - aborted, - }) + 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 @@ -551,6 +547,7 @@ export const layer: Layer.Layer< usage: value.usage, metadata: value.providerMetadata, }) + 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 f1181bc9bdf..1816f8a079d 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1733,7 +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 }) + 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 }, + ), +)