diff --git a/.changeset/sturdy-finish-guard.md b/.changeset/sturdy-finish-guard.md new file mode 100644 index 00000000000..c308018de97 --- /dev/null +++ b/.changeset/sturdy-finish-guard.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Prevent an infinite agent loop when a provider ends the response stream without a terminal stop reason. diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 760d9e50bec..69fa84bcac7 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1677,6 +1677,21 @@ NOTE: At any point in time through this workflow you should feel free to ask the return "break" as const } // kilocode_change end + // kilocode_change start - guard against providers that end the stream + // without a terminal stop_reason (e.g. an Anthropic-style message_delta + // with stop_reason: null followed immediately by message_stop). Without + // a finishReason, the loop-exit check at the top of the next iteration + // sees a falsy `finish` (loaded from storage via filterCompactedEffect) + // and keeps stepping forever. Default to "unknown" and persist so the + // regular break condition fires when there are no tool calls. Skipped + // for the compact path so guardCompactionAttempt can still fill in + // "error" on exhaustion. Tool-call turns already get "tool-calls" from + // the AI SDK; even without it, !hasToolCalls keeps the break gated. + if (result !== "compact" && !handle.message.finish) { + handle.message.finish = "unknown" + yield* sessions.updateMessage(handle.message) + } + // kilocode_change end return "continue" as const }).pipe(Effect.ensuring(instruction.clear(handle.message.id))) if (outcome === "break") break