Skip to content
Draft
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
4 changes: 4 additions & 0 deletions packages/opencode/src/session/llm/ai-sdk.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { FinishReason, LLMEvent, ProviderMetadata, ToolResultValue } from "@opencode-ai/llm"
import { Effect, Schema } from "effect"
import { type streamText } from "ai"
import { ProviderError } from "@/provider/error"
import { errorMessage } from "@/util/error"

type Result = Awaited<ReturnType<typeof streamText>>
Expand Down Expand Up @@ -86,6 +87,9 @@ export function toLLMEvents(

case "finish-step":
return Effect.sync(() => {
if (event.finishReason === "other" && event.rawFinishReason === undefined) {
throw new ProviderError.ResponseStreamError("Provider stream ended without a finish reason")
}
const original = providerMetadata(event.providerMetadata)
const metadata =
state.copilotTotalNanoAiu === undefined
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/src/tool/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ export const TaskTool = Tool.define(
agent: next.name,
parts,
})
if (result.info.role === "assistant" && result.info.error)
return yield* Effect.fail(new Error(result.info.error.name))
return result.parts.findLast((item) => item.type === "text")?.text ?? ""
})

Expand Down
27 changes: 27 additions & 0 deletions packages/opencode/test/session/llm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,33 @@ describe("session.llm.ai-sdk adapter", () => {
expect(stepFinish.usage).toBeUndefined()
})

test("fails a synthesized finish without a provider finish reason", async () => {
const exit = await Effect.runPromise(
LLMAISDK.toLLMEvents(LLMAISDK.adapterState(), {
type: "finish-step",
response: { id: "response-1", timestamp: new Date(0), modelId: "gpt-test" },
finishReason: "other",
rawFinishReason: undefined,
providerMetadata: undefined,
usage: {
inputTokens: undefined,
outputTokens: undefined,
totalTokens: undefined,
inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined },
outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
},
}).pipe(Effect.exit),
)

expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isSuccess(exit)) throw new Error("expected provider stream error")
const error = Cause.squash(exit.cause)
expect(error).toMatchObject({
name: "ProviderResponseStreamError",
message: "Provider stream ended without a finish reason",
})
})

test("reuses adapter state cleanly across streams once finish has fired", async () => {
// adapterState() is meant to be per-stream, but the only thing finish currently clears
// is toolNames — step, text counters, and the current text/reasoning IDs all leak
Expand Down
25 changes: 25 additions & 0 deletions packages/opencode/test/session/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2203,6 +2203,31 @@ it.instance("does not loop empty assistant turns for a simple reply", () =>
}),
)

it.instance("retries a provider stream that ends without a finish reason", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const session = yield* sessions.create({ title: "Truncated stream" })

yield* llm.push(reply().reason("partial reasoning"), reply().text("recovered").stop())

const result = yield* prompt.prompt({
sessionID: session.id,
agent: "build",
parts: [{ type: "text", text: "Respond after retrying" }],
})

expect(result.info.role).toBe("assistant")
if (result.info.role === "assistant") {
expect(result.info.finish).toBe("stop")
expect(result.info.error).toBeUndefined()
}
expect(result.parts.some((part) => part.type === "text" && part.text === "recovered")).toBe(true)
expect(yield* llm.calls).toBe(2)
}),
)

it.instance("records aborted errors when prompt is cancelled mid-stream", () =>
Effect.gen(function* () {
const { llm } = yield* useServerConfig(providerCfg)
Expand Down
49 changes: 46 additions & 3 deletions packages/opencode/test/tool/task.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,19 +96,27 @@ const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") {
return { chat, assistant }
})

function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; text?: string }): TaskPromptOps {
function stubOps(opts?: {
onPrompt?: (input: SessionPrompt.PromptInput) => void
text?: string
error?: NonNullable<SessionV1.Assistant["error"]>
}): TaskPromptOps {
return {
cancel: () => Effect.void,
resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]),
prompt: (input) =>
Effect.sync(() => {
opts?.onPrompt?.(input)
return reply(input, opts?.text ?? "done")
return reply(input, opts?.text ?? "done", opts?.error)
}),
}
}

function reply(input: SessionPrompt.PromptInput, text: string): SessionV1.WithParts {
function reply(
input: SessionPrompt.PromptInput,
text: string,
error?: NonNullable<SessionV1.Assistant["error"]>,
): SessionV1.WithParts {
const id = MessageID.ascending()
return {
info: {
Expand All @@ -125,6 +133,7 @@ function reply(input: SessionPrompt.PromptInput, text: string): SessionV1.WithPa
providerID: input.model?.providerID ?? ref.providerID,
time: { created: Date.now() },
finish: "stop",
error,
},
parts: [
{
Expand Down Expand Up @@ -181,6 +190,40 @@ describe("tool.task", () => {
},
)

it.instance("execute fails when the child session returns an error", () =>
Effect.gen(function* () {
const { chat, assistant } = yield* seed()
const tool = yield* TaskTool
const def = yield* tool.init()

const exit = yield* def
.execute(
{
description: "inspect bug",
prompt: "look into the cache key path",
subagent_type: "general",
},
{
sessionID: chat.id,
messageID: assistant.id,
agent: "build",
abort: new AbortController().signal,
extra: {
promptOps: stubOps({
error: new SessionV1.APIError({ message: "Provider stream failed", isRetryable: true }).toObject(),
}),
},
messages: [],
metadata: () => Effect.void,
ask: () => Effect.void,
},
)
.pipe(Effect.exit)

expect(Exit.isFailure(exit)).toBe(true)
}),
)

it.instance(
"description hides denied subagents for the caller",
() =>
Expand Down
Loading