fix: preserve unexpected provider finish reasons

This commit is contained in:
marius-kilocode
2026-07-23 19:05:40 +02:00
parent e6f4216088
commit 2fcb137ebc
34 changed files with 144 additions and 9 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"@kilocode/cli": patch
"@kilocode/sdk": patch
"kilo-code": patch
---
Preserve unexpected provider finish reasons and show the Gateway generation ID when a response ends unexpectedly.
+1
View File
@@ -253,6 +253,7 @@ export const StepFinishPart = Schema.Struct({
modelID: ModelV2.ID,
}),
),
generationID: Schema.optional(Schema.String), // kilocode_change
metrics: Schema.optional(
Schema.Struct({
prompt: Schema.optional(Schema.Finite),
@@ -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 {
@@ -70,6 +70,38 @@ describe("terminal", () => {
expect(terminal({ reason: "completed", messages: [message("other")], todos: [] })?.kind).toBe("unexpected")
})
it("includes a gateway generation id for unexpected provider finishes", () => {
const parts: Part[] = [
{
id: "p1",
sessionID: "s1",
messageID: "m1",
type: "step-finish",
reason: "other",
generationID: "gen_test",
},
]
expect(
terminal({ reason: "completed", messages: [message("other")], todos: [], parts: () => parts }),
).toMatchObject({ kind: "unexpected", finish: "other", generationID: "gen_test" })
})
it("does not expose generation ids for other terminal outcomes", () => {
const parts: Part[] = [
{
id: "p1",
sessionID: "s1",
messageID: "m1",
type: "step-finish",
reason: "unknown",
generationID: "gen_test",
},
]
expect(
terminal({ reason: "completed", messages: [message("unknown")], todos: [], parts: () => parts }),
).not.toHaveProperty("generationID")
})
it("surfaces interruption and failures without a rendered error", () => {
expect(terminal({ reason: "interrupted", messages: [message("stop")], todos: [todo("pending")] })).toEqual({
kind: "interrupted",
@@ -1,4 +1,4 @@
import { Card } from "@kilocode/kilo-ui/card"
import { Card, CardDescription } from "@kilocode/kilo-ui/card"
import { type Component, Show, createMemo } from "solid-js"
import { useSession } from "../../context/session"
import { terminal, type TerminalState } from "../../context/session-outcome"
@@ -12,6 +12,7 @@ export const TurnOutcome: Component = () => {
reason: session.closeReason(),
messages: session.visibleMessages(),
todos: session.todos(),
parts: session.getParts,
hidden: session.isErrorHidden,
}),
)
@@ -37,6 +38,13 @@ export const TurnOutcome: Component = () => {
<Card variant={value().tone === "critical" ? "error" : "warning"}>
<div>{label(value())}</div>
<Show when={value().kind === "unknown" && value().vercelID}>{(id) => <code>Request ID: {id()}</code>}</Show>
<Show when={value().generationID}>
{(id) => (
<CardDescription>
{language.t("session.outcome.generationId", { id: id() })}
</CardDescription>
)}
</Show>
</Card>
</div>
)}
@@ -1,4 +1,4 @@
import type { Message, SessionCloseReason, TodoItem } from "../types/messages"
import type { Message, Part, SessionCloseReason, TodoItem } from "../types/messages"
type TerminalKind = "incomplete" | "limit" | "unknown" | "filtered" | "unexpected" | "interrupted" | "error"
type TerminalTone = "warning" | "critical"
@@ -8,6 +8,7 @@ export interface TerminalState {
tone: TerminalTone
finish?: string
vercelID?: string
generationID?: string
remaining: number
}
@@ -15,6 +16,7 @@ interface Input {
reason?: SessionCloseReason
messages: Message[]
todos: TodoItem[]
parts?: (messageID: string) => Part[]
hidden?: (id: string) => boolean
}
@@ -26,10 +28,19 @@ function vercelID(message: Message | undefined) {
)?.[1]
}
function generation(parts: Part[]) {
const part = parts.findLast(
(item): item is Extract<Part, { type: "step-finish" }> => item.type === "step-finish" && item.reason === "other",
)
return part?.generationID
}
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 generationID =
finish === "other" && last?.role === "assistant" ? generation(input.parts?.(last.id) ?? []) : undefined
const remaining = input.todos.filter((item) => item.status !== "completed" && item.status !== "cancelled").length
if (input.reason === "interrupted") return { kind: "interrupted", tone: "warning", finish, remaining }
@@ -40,6 +51,6 @@ export function terminal(input: Input): TerminalState | undefined {
if (finish === "length") return { kind: "limit", tone: "warning", finish, remaining }
if (finish === "unknown") return { kind: "unknown", tone: "warning", finish, remaining, vercelID: vercelID(last) }
if (finish === "content-filter") return { kind: "filtered", tone: "warning", finish, remaining }
if (finish === "other") return { kind: "unexpected", tone: "warning", finish, remaining }
if (finish === "other") return { kind: "unexpected", tone: "warning", finish, generationID, remaining }
return undefined
}
+1
View File
@@ -1282,6 +1282,7 @@ export const dict = {
"session.outcome.unknown": "انتهى الدور بدون سبب إنهاء من النموذج",
"session.outcome.filtered": "أوقف المزود هذه الاستجابة بسبب عامل تصفية المحتوى.",
"session.outcome.unexpected": "انتهت الاستجابة بشكل غير متوقع وقد تكون غير مكتملة.",
"session.outcome.generationId": "معرّف التوليد: {{id}}",
"session.outcome.interrupted": "تمت مقاطعة الدور",
"session.outcome.error": "فشل الدور",
"session.outcome.finish": "سبب الإنهاء: {{reason}}",
+1
View File
@@ -1308,6 +1308,7 @@ export const dict = {
"session.outcome.unknown": "Turno encerrado sem um motivo de conclusão do modelo",
"session.outcome.filtered": "O provedor interrompeu esta resposta devido a um filtro de conteúdo.",
"session.outcome.unexpected": "A resposta terminou inesperadamente e pode estar incompleta.",
"session.outcome.generationId": "ID da geração: {{id}}",
"session.outcome.interrupted": "Turno interrompido",
"session.outcome.error": "Turno falhou",
"session.outcome.finish": "Motivo da conclusão: {{reason}}",
+1
View File
@@ -1311,6 +1311,7 @@ export const dict = {
"session.outcome.unknown": "Potez završen bez razloga završetka modela",
"session.outcome.filtered": "Provajder je zaustavio ovaj odgovor zbog filtera sadržaja.",
"session.outcome.unexpected": "Odgovor je neočekivano završen i može biti nepotpun.",
"session.outcome.generationId": "ID generiranja: {{id}}",
"session.outcome.interrupted": "Potez prekinut",
"session.outcome.error": "Potez nije uspio",
"session.outcome.finish": "Razlog završetka: {{reason}}",
+1
View File
@@ -1304,6 +1304,7 @@ export const dict = {
"session.outcome.unknown": "Tur afsluttet uden en model-afslutningsårsag",
"session.outcome.filtered": "Udbyderen stoppede dette svar på grund af et indholdsfilter.",
"session.outcome.unexpected": "Svaret sluttede uventet og kan være ufuldstændigt.",
"session.outcome.generationId": "Genererings-id: {{id}}",
"session.outcome.interrupted": "Tur afbrudt",
"session.outcome.error": "Tur mislykkedes",
"session.outcome.finish": "Afslutningsårsag: {{reason}}",
@@ -1325,6 +1325,7 @@ export const dict = {
"session.outcome.unknown": "Zug ohne Modell-Abschlussgrund beendet",
"session.outcome.filtered": "Der Anbieter hat diese Antwort aufgrund eines Inhaltsfilters gestoppt.",
"session.outcome.unexpected": "Die Antwort wurde unerwartet beendet und ist möglicherweise unvollständig.",
"session.outcome.generationId": "Generierungs-ID: {{id}}",
"session.outcome.interrupted": "Zug unterbrochen",
"session.outcome.error": "Zug fehlgeschlagen",
"session.outcome.finish": "Abschlussgrund: {{reason}}",
@@ -1254,6 +1254,7 @@ export const dict = {
"session.outcome.unknown": "Response ended without a finish reason and may be incomplete.",
"session.outcome.filtered": "The provider stopped this response because of a content filter.",
"session.outcome.unexpected": "Response ended unexpectedly and may be incomplete.",
"session.outcome.generationId": "Generation ID: {{id}}",
"session.outcome.interrupted": "Turn interrupted.",
"session.outcome.error": "Turn failed.",
"session.outcome.finish": "Technical finish reason: {{reason}}",
+1
View File
@@ -1318,6 +1318,7 @@ export const dict = {
"session.outcome.unknown": "Turno finalizado sin motivo de finalización del modelo",
"session.outcome.filtered": "El proveedor detuvo esta respuesta debido a un filtro de contenido.",
"session.outcome.unexpected": "La respuesta terminó inesperadamente y puede estar incompleta.",
"session.outcome.generationId": "ID de generación: {{id}}",
"session.outcome.interrupted": "Turno interrumpido",
"session.outcome.error": "Turno fallido",
"session.outcome.finish": "Motivo de finalización: {{reason}}",
+1
View File
@@ -1329,6 +1329,7 @@ export const dict = {
"session.outcome.unknown": "Tour terminé sans motif de fin du modèle",
"session.outcome.filtered": "Le fournisseur a arrêté cette réponse en raison d'un filtre de contenu.",
"session.outcome.unexpected": "La réponse s'est terminée de manière inattendue et peut être incomplète.",
"session.outcome.generationId": "ID de génération : {{id}}",
"session.outcome.interrupted": "Tour interrompu",
"session.outcome.error": "Échec du tour",
"session.outcome.finish": "Motif de fin : {{reason}}",
+1
View File
@@ -1692,6 +1692,7 @@ export const dict = {
"session.outcome.unknown": "La risposta è terminata senza un motivo di fine e potrebbe essere incompleta.",
"session.outcome.filtered": "Il provider ha interrotto questa risposta a causa di un filtro contenuti.",
"session.outcome.unexpected": "La risposta è terminata inaspettatamente e potrebbe essere incompleta.",
"session.outcome.generationId": "ID della generazione: {{id}}",
"session.outcome.interrupted": "Turno interrotto.",
"session.outcome.error": "Turno fallito.",
"session.outcome.finish": "Motivo tecnico di fine: {{reason}}",
+1
View File
@@ -1299,6 +1299,7 @@ export const dict = {
"session.outcome.unknown": "モデルの終了理由なしでターンが終了しました",
"session.outcome.filtered": "コンテンツフィルターにより、プロバイダーがこの応答を停止しました。",
"session.outcome.unexpected": "応答が予期せず終了したため、不完全である可能性があります。",
"session.outcome.generationId": "生成 ID: {{id}}",
"session.outcome.interrupted": "ターンが中断されました",
"session.outcome.error": "ターンが失敗しました",
"session.outcome.finish": "終了理由: {{reason}}",
+1
View File
@@ -1295,6 +1295,7 @@ export const dict = {
"session.outcome.unknown": "모델의 종료 이유 없이 턴이 종료되었습니다",
"session.outcome.filtered": "콘텐츠 필터로 인해 제공자가 이 응답을 중단했습니다.",
"session.outcome.unexpected": "응답이 예기치 않게 종료되었으며 불완전할 수 있습니다.",
"session.outcome.generationId": "생성 ID: {{id}}",
"session.outcome.interrupted": "턴이 중단되었습니다",
"session.outcome.error": "턴이 실패했습니다",
"session.outcome.finish": "종료 이유: {{reason}}",
+1
View File
@@ -1299,6 +1299,7 @@ export const dict = {
"session.outcome.unknown": "Beurt beëindigd zonder voltooiingsreden van het model",
"session.outcome.filtered": "De provider heeft deze reactie gestopt vanwege een contentfilter.",
"session.outcome.unexpected": "Reactie is onverwacht beëindigd en is mogelijk onvolledig.",
"session.outcome.generationId": "Generatie-ID: {{id}}",
"session.outcome.interrupted": "Beurt onderbroken",
"session.outcome.error": "Beurt mislukt",
"session.outcome.finish": "Voltooiingsreden: {{reason}}",
+1
View File
@@ -1264,6 +1264,7 @@ export const dict = {
"session.outcome.unknown": "Runden ble avsluttet uten avslutningsårsak fra modellen",
"session.outcome.filtered": "Leverandøren stoppet denne responsen på grunn av et innholdsfilter.",
"session.outcome.unexpected": "Responsen ble avsluttet uventet og kan være ufullstendig.",
"session.outcome.generationId": "Genererings-ID: {{id}}",
"session.outcome.interrupted": "Runde avbrutt",
"session.outcome.error": "Runden feilet",
"session.outcome.finish": "Avslutningsårsak: {{reason}}",
+1
View File
@@ -1263,6 +1263,7 @@ export const dict = {
"session.outcome.unknown": "Tura zakończona bez powodu zakończenia od modelu",
"session.outcome.filtered": "Dostawca zatrzymał tę odpowiedź z powodu filtru treści.",
"session.outcome.unexpected": "Odpowiedź zakończyła się nieoczekiwanie i może być niekompletna.",
"session.outcome.generationId": "ID generowania: {{id}}",
"session.outcome.interrupted": "Tura przerwana",
"session.outcome.error": "Tura nie powiodła się",
"session.outcome.finish": "Powód zakończenia: {{reason}}",
+1
View File
@@ -1310,6 +1310,7 @@ export const dict = {
"session.outcome.unknown": "Раунд завершен без указания причины моделью",
"session.outcome.filtered": "Провайдер остановил этот ответ из-за фильтра контента.",
"session.outcome.unexpected": "Ответ неожиданно завершился и может быть неполным.",
"session.outcome.generationId": "Идентификатор генерации: {{id}}",
"session.outcome.interrupted": "Раунд прерван",
"session.outcome.error": "Раунд завершился с ошибкой",
"session.outcome.finish": "Причина завершения: {{reason}}",
+1
View File
@@ -1291,6 +1291,7 @@ export const dict = {
"session.outcome.unknown": "เทิร์นสิ้นสุดโดยไม่มีเหตุผลการเสร็จสิ้นของโมเดล",
"session.outcome.filtered": "ผู้ให้บริการหยุดการตอบกลับนี้เนื่องจากตัวกรองเนื้อหา",
"session.outcome.unexpected": "การตอบกลับสิ้นสุดลงโดยไม่คาดคิดและอาจไม่สมบูรณ์",
"session.outcome.generationId": "รหัสการสร้าง: {{id}}",
"session.outcome.interrupted": "เทิร์นถูกขัดจังหวะ",
"session.outcome.error": "เทิร์นล้มเหลว",
"session.outcome.finish": "เหตุผลการเสร็จสิ้น: {{reason}}",
+1
View File
@@ -1295,6 +1295,7 @@ export const dict = {
"session.outcome.unknown": "Tur, model bitiş nedeni olmadan sona erdi",
"session.outcome.filtered": "Sağlayıcı, bir içerik filtresi nedeniyle bu yanıtı durdurdu.",
"session.outcome.unexpected": "Yanıt beklenmedik bir şekilde sona erdi ve eksik olabilir.",
"session.outcome.generationId": "Oluşturma kimliği: {{id}}",
"session.outcome.interrupted": "Tur kesintiye uğradı",
"session.outcome.error": "Tur başarısız oldu",
"session.outcome.finish": "Bitiş nedeni: {{reason}}",
+1
View File
@@ -1292,6 +1292,7 @@ export const dict = {
"session.outcome.unknown": "Хід завершився без причини завершення моделі",
"session.outcome.filtered": "Провайдер зупинив цю відповідь через фільтр вмісту.",
"session.outcome.unexpected": "Відповідь завершилася несподівано і може бути неповною.",
"session.outcome.generationId": "Ідентифікатор генерації: {{id}}",
"session.outcome.interrupted": "Хід перервано",
"session.outcome.error": "Хід не вдався",
"session.outcome.finish": "Причина завершення: {{reason}}",
+1
View File
@@ -1267,6 +1267,7 @@ export const dict = {
"session.outcome.unknown": "回合已结束,模型未提供结束原因",
"session.outcome.filtered": "提供商因为内容过滤器停止了此响应。",
"session.outcome.unexpected": "响应意外结束,可能不完整。",
"session.outcome.generationId": "生成 ID{{id}}",
"session.outcome.interrupted": "回合已中断",
"session.outcome.error": "回合失败",
"session.outcome.finish": "结束原因:{{reason}}",
+1
View File
@@ -1228,6 +1228,7 @@ export const dict = {
"session.outcome.unknown": "回合已結束,模型未提供結束原因",
"session.outcome.filtered": "提供商因為內容過濾器停止了此回應。",
"session.outcome.unexpected": "回應意外結束,可能不完整。",
"session.outcome.generationId": "生成 ID{{id}}",
"session.outcome.interrupted": "回合已中斷",
"session.outcome.error": "回合失敗",
"session.outcome.finish": "結束原因:{{reason}}",
@@ -98,6 +98,7 @@ export interface StepFinishPart extends BasePart {
providerID: string
modelID: string
}
generationID?: string
cost?: number
tokens?: {
input: number
+9 -1
View File
@@ -33,7 +33,15 @@ export type TextVerbosity = Schema.Schema.Type<typeof TextVerbosity>
export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"])
export type MessageRole = Schema.Schema.Type<typeof MessageRole>
export const FinishReason = Schema.Literals(["stop", "length", "tool-calls", "content-filter", "error", "unknown"])
export const FinishReason = Schema.Literals([
"stop",
"length",
"tool-calls",
"content-filter",
"error",
"other", // kilocode_change - preserve the AI SDK's unexpected provider finish reason
"unknown",
])
export type FinishReason = Schema.Schema.Type<typeof FinishReason>
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
@@ -10,7 +10,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { Cause, Effect, Exit } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { EffectBridge } from "@/effect/bridge"
import type { LLMEvent, Usage } from "@opencode-ai/llm"
import type { LLMEvent, ProviderMetadata, Usage } from "@opencode-ai/llm"
import type { ProviderV2 } from "@opencode-ai/core/provider"
import { SessionRetry } from "@/session/retry"
import { computeMetrics as computeMetricsHelper, type TokenRates } from "@/kilocode/session/metrics"
@@ -137,6 +137,14 @@ export namespace KiloSessionProcessor {
/** Returned shape for downstream consumers that prefer the namespace. */
export type Metrics = TokenRates
export function generationID(meta: ProviderMetadata | undefined) {
const value = meta?.gateway?.generationId
if (typeof value !== "string") return
const id = value.trim()
if (!/^gen_[A-Za-z0-9_-]{1,200}$/.test(id)) return
return id
}
/**
* Effect-based offline handler for the retry schedule.
* Shows offline status, waits for network reconnection or user rejection.
@@ -869,6 +869,7 @@ export const layer = Layer.effect(
modelID: ctx.model.id,
selected: ctx.assistantMessage.modelID,
})
const generationID = KiloSessionProcessor.generationID(value.providerMetadata)
// 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
@@ -922,6 +923,7 @@ export const layer = Layer.effect(
type: "step-finish",
time: { start: startDate, end: endDate, elapsed: elapsedMs }, // kilocode_change
...(model ? { model } : {}), // kilocode_change
...(generationID ? { generationID } : {}), // kilocode_change
...(metrics ? { metrics } : {}), // kilocode_change
tokens: usage.tokens,
cost: usage.cost,
@@ -0,0 +1,23 @@
import { describe, expect, test } from "bun:test"
import { KiloSessionProcessor } from "../../src/kilocode/session/processor"
describe("session generation id", () => {
test("extracts a bounded Gateway generation id", () => {
expect(
KiloSessionProcessor.generationID({
gateway: {
generationId: " gen_test-123 ",
routing: { finalProvider: "novita" },
marketCost: "0.1",
},
}),
).toBe("gen_test-123")
})
test("rejects arbitrary or oversized metadata values", () => {
expect(KiloSessionProcessor.generationID({ gateway: { generationId: "request-secret" } })).toBeUndefined()
expect(KiloSessionProcessor.generationID({ gateway: { generationId: `gen_${"a".repeat(201)}` } })).toBeUndefined()
expect(KiloSessionProcessor.generationID({ gateway: { generationId: 42 } })).toBeUndefined()
expect(KiloSessionProcessor.generationID({ openai: { responseId: "gen_response" } })).toBeUndefined()
})
})
@@ -577,7 +577,14 @@ describe("session processor empty tool-calls", () => {
index: 0,
reason: "stop",
usage: usage(),
providerMetadata: { kilocode: { routedModelID: "openai/gpt-5.5-20260423" } },
providerMetadata: {
kilocode: { routedModelID: "openai/gpt-5.5-20260423" },
gateway: {
generationId: "gen_test",
routing: { finalProvider: "openai" },
marketCost: "0.1",
},
},
}),
LLMEvent.finish({ reason: "stop", usage: usage() }),
)
@@ -632,6 +639,9 @@ describe("session processor empty tool-calls", () => {
providerID: selection.providerID,
modelID: ModelV2.ID.make("openai/gpt-5.5-20260423"),
})
expect(part?.generationID).toBe("gen_test")
expect(part).not.toHaveProperty("providerMetadata")
expect(part).not.toHaveProperty("gateway")
}),
{ git: true },
),
+2 -2
View File
@@ -281,7 +281,7 @@ describe("session.llm.ai-sdk adapter", () => {
{
type: "step-finish",
index: 0,
reason: "unknown",
reason: "other", // kilocode_change
usage: {
inputTokens: 10,
outputTokens: 5,
@@ -294,7 +294,7 @@ describe("session.llm.ai-sdk adapter", () => {
},
{
type: "finish",
reason: "unknown",
reason: "other", // kilocode_change
usage: {
inputTokens: 11,
outputTokens: 6,
+1
View File
@@ -782,6 +782,7 @@ export type StepFinishPart = {
providerID: string
modelID: string
}
generationID?: string
metrics?: {
prompt?: number
generation?: number
+3
View File
@@ -26075,6 +26075,9 @@
"required": ["providerID", "modelID"],
"additionalProperties": false
},
"generationID": {
"type": "string"
},
"metrics": {
"type": "object",
"properties": {