Skip to content
Open
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
21 changes: 20 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1110,7 +1110,9 @@ const layer = Layer.effect(

if (
lastAssistant?.finish &&
!["tool-calls"].includes(lastAssistant.finish) &&
// A recoverable length finish must reach the continuation decision below;
// otherwise this history reload exits before the extra provider turn can run.
!["tool-calls", "length"].includes(lastAssistant.finish) &&
!hasToolCalls &&
lastUser.id < lastAssistant.id
) {
Expand Down Expand Up @@ -1292,6 +1294,23 @@ const layer = Layer.effect(
return "break" as const
}

if (handle.message.finish === "length") {
// A length finish truncates provider output. Persisted parts identify
// text or tools worth one continuation; reasoning-only output must
// surface an error instead of ending silently. The previous finish
// bounds this to one attempt without adding loop state.
const current = yield* MessageV2.get({ sessionID, messageID: handle.message.id }).pipe(
Effect.provideService(Database.Service, database),
Effect.orElseSucceed(() => undefined),
)
const usable = current?.parts.some((part) => part.type === "text" || part.type === "tool") ?? false
if (usable && lastAssistant?.finish !== "length") return "continue" as const

handle.message.error = new SessionV1.OutputLengthError({}).toObject()
yield* sessions.updateMessage(handle.message)
return "break" as const
}

const finished = handle.message.finish && !["tool-calls", "unknown"].includes(handle.message.finish)
if (finished && !handle.message.error) {
// Surface any content-filter finish (e.g. Anthropic stop_reason:
Expand Down
8 changes: 8 additions & 0 deletions packages/opencode/test/lib/llm-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,14 @@ export class Reply {
return this
}

length() {
this.#finish = "length"
this.#hang = false
this.#error = undefined
this.#reset = false
return this
}

contentFilter() {
this.#finish = "content_filter"
this.#hang = false
Expand Down
83 changes: 83 additions & 0 deletions packages/opencode/test/session/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,89 @@ it.instance("loop surfaces content-filter finishes as session errors", () =>
}),
)

// This verifies end-to-end recovery; the error and one-continuation decisions are pinned by the following cases.
it.instance("loop completes end-to-end recovery after a recoverable length finish", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned" })
yield* prompt.prompt({
sessionID: chat.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello" }],
})

yield* llm.push(reply().text("partial").length(), reply().text("complete").stop())
const result = yield* prompt.loop({ sessionID: chat.id })
expect(yield* llm.hits).toHaveLength(2)
expect(result.info).toMatchObject({ role: "assistant", finish: "stop" })
expect(result.parts).toEqual(
expect.arrayContaining([expect.objectContaining({ type: "text", text: "complete" })]),
)
}),
)

it.instance("loop surfaces reasoning-only length finish as an error", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned" })
yield* prompt.prompt({
sessionID: chat.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello" }],
})

yield* llm.push(reply().reason("unfinished reasoning").length())
const result = yield* prompt.loop({ sessionID: chat.id })
const stored = yield* MessageV2.get({ sessionID: chat.id, messageID: result.info.id })
expect(yield* llm.hits).toHaveLength(1)
expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info).toMatchObject({
finish: "length",
error: { name: "MessageOutputLengthError", data: {} },
})
expect(stored.info).toMatchObject({ error: result.info.error })
}
expect(result.parts).toEqual(
expect.arrayContaining([expect.objectContaining({ type: "reasoning", text: "unfinished reasoning" })]),
)
}),
)

it.instance("loop bounds continuation after a second length finish", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const chat = yield* sessions.create({ title: "Pinned" })
yield* prompt.prompt({
sessionID: chat.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "hello" }],
})

yield* llm.push(
reply().text("first partial").length(),
reply().text("second partial").length(),
reply().text("must not run").stop(),
)
const result = yield* prompt.loop({ sessionID: chat.id })
expect(yield* llm.hits).toHaveLength(2)
expect(result.info).toMatchObject({
role: "assistant",
finish: "length",
error: { name: "MessageOutputLengthError", data: {} },
})
}),
)

it.instance("loop stops provider overflow instead of auto-compacting when disabled", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig((url) => ({
Expand Down
Loading