Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-snapshot-warnings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---

Avoid showing incomplete-response warnings for snapshot initialization status turns.
5 changes: 5 additions & 0 deletions .changeset/tidy-responses-finish.md
Original file line number Diff line number Diff line change
@@ -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.
67 changes: 66 additions & 1 deletion packages/kilo-vscode/tests/unit/session-outcome.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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()
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
)
Expand Down
22 changes: 18 additions & 4 deletions packages/kilo-vscode/webview-ui/src/context/session-outcome.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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 }
Expand Down
20 changes: 20 additions & 0 deletions packages/kilo-vscode/webview-ui/src/context/session-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
63 changes: 62 additions & 1 deletion packages/opencode/src/kilocode/session/processor.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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<typeof emptyResponseError>[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 }
Expand Down
32 changes: 31 additions & 1 deletion packages/opencode/src/kilocode/session/prompt.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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.
Expand Down Expand Up @@ -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<Session.Interface, "updateMessage">
bus: Pick<Bus.Interface, "publish">
status: Pick<SessionStatus.Interface, "set">
close: Map<string, KiloSession.CloseReason>
}) {
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
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/kilocode/worktree-family.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export namespace WorktreeFamily {
// In a git submodule, `git worktree list --porcelain` reports the
// gitdir (`<repo>/.git/modules/<sub>`) 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))
Expand Down
7 changes: 2 additions & 5 deletions packages/opencode/src/session/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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().
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] : [])]
Expand Down
Loading
Loading