Skip to content
Closed
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
14 changes: 12 additions & 2 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,16 @@ import { makeRuntime } from "@/effect/run-service"
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false

/**
* Returns true if the given finish reason indicates the model has finished
* and the prompt loop should not continue. Only "tool-calls" is non-terminal
* (the model needs tool results to continue). All other reasons — including
* "unknown" from non-standard providers — are treated as terminal.
*/
export function isTerminalFinishReason(reason: string): boolean {
return reason !== "tool-calls"
}

const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format.

IMPORTANT:
Expand Down Expand Up @@ -1372,7 +1382,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the

if (
lastAssistant?.finish &&
!["tool-calls"].includes(lastAssistant.finish) &&
isTerminalFinishReason(lastAssistant.finish) &&
!hasToolCalls &&
lastUser.id < lastAssistant.id
) {
Expand Down Expand Up @@ -1527,7 +1537,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
return "break" as const
}

const finished = handle.message.finish && !["tool-calls", "unknown"].includes(handle.message.finish)
const finished = handle.message.finish && isTerminalFinishReason(handle.message.finish)
if (finished && !handle.message.error) {
if (format.type === "json_schema") {
handle.message.error = new MessageV2.StructuredOutputError({
Expand Down
40 changes: 40 additions & 0 deletions packages/opencode/test/session/finish-reason.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, test } from "bun:test"
import { isTerminalFinishReason } from "../../src/session/prompt"

describe("isTerminalFinishReason", () => {
test("tool-calls is non-terminal", () => {
expect(isTerminalFinishReason("tool-calls")).toBe(false)
})

test("stop is terminal", () => {
expect(isTerminalFinishReason("stop")).toBe(true)
})

test("length is terminal", () => {
expect(isTerminalFinishReason("length")).toBe(true)
})

test("content-filter is terminal", () => {
expect(isTerminalFinishReason("content-filter")).toBe(true)
})

test("error is terminal", () => {
expect(isTerminalFinishReason("error")).toBe(true)
})

test("other is terminal", () => {
expect(isTerminalFinishReason("other")).toBe(true)
})

test("unknown is terminal (non-standard providers like GLM-5 Turbo)", () => {
expect(isTerminalFinishReason("unknown")).toBe(true)
})

test("end_turn is terminal (Anthropic)", () => {
expect(isTerminalFinishReason("end_turn")).toBe(true)
})

test("empty string is terminal (defensive)", () => {
expect(isTerminalFinishReason("")).toBe(true)
})
})
Loading