Compare commits

..
3 Commits
19 changed files with 158 additions and 117 deletions
+5 -14
View File
@@ -20,7 +20,6 @@ import {
LanguageModel,
LanguageModelLimits,
LLMEvent,
InvalidProviderOutputReason,
ProviderID,
mergeGenerationOptions,
mergeHttpOptions,
@@ -232,17 +231,6 @@ const streamError = (route: string, message: string, cause: Cause.Cause<unknown>
return ProviderShared.eventError(route, message, Cause.pretty(cause))
}
const incompleteStreamError = (route: string) =>
new AIError({
module: "LLMClient",
method: "stream",
reason: new InvalidProviderOutputReason({
classification: "incomplete-stream",
message: "The provider response ended unexpectedly.",
route,
}),
})
const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent, AIError>) =>
Stream.suspend(() => {
let terminal = false
@@ -259,7 +247,7 @@ const requireTerminalEvent = (route: string) => (events: Stream.Stream<LLMEvent,
Effect.suspend(() =>
terminal
? Effect.void
: Effect.fail(incompleteStreamError(route)),
: Effect.fail(ProviderShared.eventError(route, "Provider stream ended without a terminal finish event")),
),
),
)
@@ -428,7 +416,10 @@ const generateWith = (stream: Interface["stream"]) =>
const state = yield* stream(request, options).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
const response = LLMResponse.complete(state)
if (response) return response
return yield* incompleteStreamError(`${request.model.provider}/${request.model.route.id}`)
return yield* ProviderShared.eventError(
`${request.model.provider}/${request.model.route.id}`,
"Provider stream ended without a terminal finish event",
)
})
export function stream(request: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError, Service> {
-1
View File
@@ -105,7 +105,6 @@ export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOut
)({
_tag: Schema.tag("InvalidProviderOutput"),
message: Schema.String,
classification: Schema.optional(Schema.Literals(["incomplete-stream"])),
route: Schema.optional(Schema.String),
raw: Schema.optional(Schema.String),
providerMetadata: Schema.optional(ProviderMetadata),
+2 -2
View File
@@ -133,8 +133,8 @@ describe("llm route", () => {
Effect.gen(function* () {
const error = yield* (yield* LLMClient.Service).stream(request).pipe(Stream.runDrain, Effect.flip)
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput", classification: "incomplete-stream" })
expect(error.message).toContain("The provider response ended unexpectedly.")
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
expect(error.message).toContain("Provider stream ended without a terminal finish event")
}),
)
@@ -538,8 +538,7 @@ describe("Anthropic Messages route", () => {
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
classification: "incomplete-stream",
message: "The provider response ended unexpectedly.",
message: "Provider stream ended without a terminal finish event",
})
}),
)
@@ -1136,12 +1136,9 @@ describe("OpenAI Chat route", () => {
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
])
expect(events.filter(LLMEvent.is.toolCall)).toEqual([])
expect(streamError.reason).toMatchObject({
_tag: "InvalidProviderOutput",
classification: "incomplete-stream",
})
expect(streamError.message).toContain("The provider response ended unexpectedly.")
expect(error.message).toContain("The provider response ended unexpectedly.")
expect(streamError.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
expect(streamError.message).toContain("Provider stream ended without a terminal finish event")
expect(error.message).toContain("Provider stream ended without a terminal finish event")
}),
)
@@ -53,7 +53,7 @@ describe("normalizePermissionRequest", () => {
resources: ["README.md"],
save: ["*.md"],
metadata: { path: "README.md" },
source: { type: "tool", messageID: "message-1", id: "call-1" },
source: { type: "tool", messageID: "message-1", callID: "call-1" },
}),
).toEqual({
id: "permission-1",
@@ -48,7 +48,7 @@ export function normalizePermissionRequest(input: PermissionRequest | LegacyPerm
always: input.save ?? [],
metadata: input.metadata ?? {},
tool:
input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.id } : undefined,
input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.callID } : undefined,
}
}
+2 -34
View File
@@ -21,24 +21,12 @@ describe("adaptServerEvent", () => {
id: "evt_1",
created: 1,
type: "permission.asked",
data: {
id: "perm_1",
sessionID: "ses_1",
action: "read",
resources: ["src/**"],
source: { type: "tool", messageID: "msg_1", id: "call_1" },
},
data: { id: "perm_1", sessionID: "ses_1", action: "read", resources: ["src/**"] },
} as OpenCodeEvent
expect(adaptServerEvent(current)).toMatchObject({
type: "permission.asked",
properties: {
id: "perm_1",
sessionID: "ses_1",
permission: "read",
patterns: ["src/**"],
tool: { messageID: "msg_1", callID: "call_1" },
},
properties: { id: "perm_1", sessionID: "ses_1", permission: "read", patterns: ["src/**"] },
current,
})
})
@@ -82,26 +70,6 @@ describe("coalesceServerEvents", () => {
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
})
test("coalesces current tool input deltas by tool ID", () => {
const current = (eventID: string, id: string, delta: string) =>
adaptServerEvent({
id: eventID,
created: 1,
type: "session.tool.input.delta",
location: { directory: "/repo" },
data: { sessionID: "ses", assistantMessageID: "msg", id, delta },
} as OpenCodeEvent)
const result = coalesceServerEvents([
{ directory: "/repo", payload: current("evt_1", "call_1", "{") },
{ directory: "/repo", payload: current("evt_2", "call_1", "}") },
{ directory: "/repo", payload: current("evt_3", "call_2", "[]") },
])
expect(result).toHaveLength(2)
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
expect(result[1]?.payload.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
})
test("preserves event boundaries and distinct fields", () => {
const status = {
directory: "/repo",
+2 -2
View File
@@ -39,7 +39,7 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
metadata: event.data.metadata ?? {},
tool:
event.data.source?.type === "tool"
? { messageID: event.data.source.messageID, callID: event.data.source.id }
? { messageID: event.data.source.messageID, callID: event.data.source.callID }
: undefined,
},
current: event,
@@ -142,7 +142,7 @@ function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefine
function currentDeltaKey(event: CurrentDelta) {
if (event.type === "session.tool.input.delta")
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.id}`
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.callID}`
if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}`
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}`
}
@@ -92,19 +92,19 @@ describe("v2 session reducer", () => {
...base,
id: "evt_tool_start",
type: "session.tool.input.started",
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", id: "call_1", name: "bash" },
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", name: "bash" },
})
apply({
...base,
id: "evt_tool_delta",
type: "session.tool.input.delta",
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", id: "call_1", delta: "{}" },
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", delta: "{}" },
})
apply({
...base,
id: "evt_tool_called",
type: "session.tool.called",
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", id: "call_1", input: {}, executed: true },
data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", input: {}, executed: true },
})
apply({
...base,
@@ -113,7 +113,7 @@ describe("v2 session reducer", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
id: "call_1",
callID: "call_1",
metadata: {},
content: [{ type: "text", text: "done" }],
executed: true,
@@ -241,13 +241,13 @@ export function createV2SessionReducer() {
case "session.tool.input.started":
return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({
...item,
content: item.content.some((content) => content.type === "tool" && content.id === event.data.id)
content: item.content.some((content) => content.type === "tool" && content.id === event.data.callID)
? item.content
: [
...item.content,
{
type: "tool",
id: event.data.id,
id: event.data.callID,
name: event.data.name,
state: { status: "streaming", input: "" },
time: { created: event.created },
@@ -255,17 +255,17 @@ export function createV2SessionReducer() {
],
}))
case "session.tool.input.delta":
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) =>
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
tool.state.status === "streaming"
? { ...tool, state: { ...tool.state, input: tool.state.input + event.data.delta } }
: tool,
)
case "session.tool.input.ended":
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) =>
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
tool.state.status === "streaming" ? { ...tool, state: { ...tool.state, input: event.data.text } } : tool,
)
case "session.tool.called":
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) => ({
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => ({
...tool,
executed: event.data.executed,
providerState: event.data.state,
@@ -274,7 +274,7 @@ export function createV2SessionReducer() {
time: { ...tool.time, ran: event.created },
}))
case "session.tool.progress":
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) =>
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) =>
tool.state.status === "running"
? {
...tool,
@@ -284,7 +284,7 @@ export function createV2SessionReducer() {
: tool,
)
case "session.tool.success":
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) => {
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => {
if (tool.state.status !== "running") return tool
return {
...tool,
@@ -302,7 +302,7 @@ export function createV2SessionReducer() {
}
})
case "session.tool.failed":
return updateTool(source, event.data.assistantMessageID, event.data.id, sessionID, (tool) => {
return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => {
if (tool.state.status !== "streaming" && tool.state.status !== "running") return tool
return {
...tool,
+1 -1
View File
@@ -470,7 +470,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.step.failed") {
if (
input.compatibility === "v1" &&
event.data.error.message === "The provider response ended unexpectedly."
event.data.error.message === "Provider stream ended without a terminal finish event"
) {
pendingStep = undefined
v1InvalidOutput = true
+2 -2
View File
@@ -503,8 +503,8 @@ describe("runNonInteractivePrompt", () => {
turn: (messageID) => [
prompted(messageID),
stepStarted(),
stepFailed("The provider response ended unexpectedly."),
executionFailed("The provider response ended unexpectedly."),
stepFailed("Provider stream ended without a terminal finish event"),
executionFailed("Provider stream ended without a terminal finish event"),
],
})
+1 -2
View File
@@ -20,11 +20,10 @@ export function isRetryable(error: AIError) {
case "ProviderInternal":
case "Transport":
return true
case "InvalidProviderOutput":
return error.reason.classification === "incomplete-stream"
case "Authentication":
case "QuotaExceeded":
case "ContentPolicy":
case "InvalidProviderOutput":
case "InvalidRequest":
case "NoRoute":
case "UnknownProvider":
+42 -3
View File
@@ -1,6 +1,7 @@
import { AIError, ToolFailure } from "@opencode-ai/ai"
import { Tool } from "@opencode-ai/schema/tool"
import { SessionError } from "@opencode-ai/schema/session-error"
import { PlatformError } from "effect/PlatformError"
import { Permission } from "../permission"
import { Question } from "../question"
import { Integration } from "../integration"
@@ -40,10 +41,11 @@ export function toSessionError(cause: unknown): SessionError.Error {
if (cause instanceof Question.RejectedError) return { type: "aborted", message: cause.message }
if (cause instanceof ToolFailure || cause instanceof Tool.Error) {
if (cause.error === undefined) return { type: "tool.execution", message: cause.message }
// The canonical error is the sole model-visible representation, so a cause
// with no message must not erase the tool's curated failure message.
if (cause.error instanceof PlatformError)
return { type: "tool.execution", message: `${cause.message}: ${platformErrorMessage(cause.error)}` }
const unwrapped = toSessionError(cause.error)
return unwrapped.message === "" ? { ...unwrapped, type: "tool.execution", message: cause.message } : unwrapped
if (unwrapped.message === "") return { ...unwrapped, type: "tool.execution", message: cause.message }
return unwrapped
}
if (cause instanceof StepFailedError) return cause.error
if (cause instanceof AgentNotFoundError) return { type: "unknown", message: cause.message }
@@ -56,6 +58,7 @@ export function toSessionError(cause: unknown): SessionError.Error {
)
return { type: "provider.no-route", message: cause.message }
if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message }
if (cause instanceof PlatformError) return { type: "unknown", message: platformErrorMessage(cause) }
return { type: "unknown", message: cause instanceof Error ? cause.message : String(cause) }
}
@@ -64,3 +67,39 @@ function providerError(type: string, reason: AIError["reason"]): SessionError.Er
("http" in reason ? reason.http?.response?.status : undefined) ?? ("status" in reason ? reason.status : undefined)
return { type, message: reason.message, ...(status === undefined ? {} : { status }) }
}
function platformErrorMessage(error: PlatformError) {
const reason = error.reason
if (reason._tag === "BadArgument")
return `invalid argument${reason.description ? `: ${reason.description}` : ""}`
const label = (() => {
switch (reason._tag) {
case "AlreadyExists":
return "already exists"
case "BadResource":
return "resource is invalid or closed"
case "Busy":
return "resource is busy"
case "InvalidData":
return "invalid data"
case "NotFound":
return "not found"
case "PermissionDenied":
return "permission denied"
case "TimedOut":
return "timed out"
case "UnexpectedEof":
return "unexpected end of input"
case "Unknown":
return "system error"
case "WouldBlock":
return "would block"
case "WriteZero":
return "wrote zero bytes"
}
})()
const target = reason.pathOrDescriptor === undefined ? "" : `: ${reason.pathOrDescriptor}`
const description = reason.description === undefined ? "" : ` (${reason.description})`
return `${label}${target}${description}`
}
+10 -2
View File
@@ -176,7 +176,12 @@ export const Plugin = {
agent: context.agent,
source,
})
if ((yield* fsUtil.stat(target.canonical)).type !== "Directory")
const workdir = yield* fsUtil.stat(target.canonical).pipe(
Effect.catchReason("PlatformError", "NotFound", () =>
Effect.fail(new ToolFailure({ message: `Working directory does not exist: ${target.canonical}` })),
),
)
if (workdir.type !== "Directory")
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
}),
)
@@ -280,7 +285,10 @@ export const Plugin = {
}
}),
Effect.mapError(
(error) => new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
(error) =>
error instanceof ToolFailure
? error
: new ToolFailure({ message: `Unable to execute command: ${input.command}`, error }),
),
),
}),
+46
View File
@@ -22,6 +22,7 @@ import { Permission } from "@opencode-ai/core/permission"
import { Tool } from "@opencode-ai/schema/tool"
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
import { badArgument, systemError } from "effect/PlatformError"
const llm = (reason: AIError["reason"]) => new AIError({ module: "test", method: "stream", reason })
@@ -72,6 +73,26 @@ describe("toSessionError", () => {
type: "tool.execution",
message: "failed",
})
expect(
toSessionError(
new ToolFailure({
message: "Unable to execute command: pwd",
error: systemError({
_tag: "NotFound",
module: "FileSystem",
method: "stat",
pathOrDescriptor: "/missing",
}),
}),
),
).toEqual({
type: "tool.execution",
message: "Unable to execute command: pwd: not found: /missing",
})
expect(toSessionError(new ToolFailure({ message: "Tool failed", error: new Error("") }))).toEqual({
type: "tool.execution",
message: "Tool failed",
})
})
test("preserves provider HTTP status", () => {
@@ -91,6 +112,31 @@ describe("toSessionError", () => {
})
})
test("formats platform errors for the model", () => {
const missing = systemError({
_tag: "NotFound",
module: "FileSystem",
method: "operation",
pathOrDescriptor: "/target",
description: "OS detail",
})
expect(toSessionError(missing)).toEqual({
type: "unknown",
message: "not found: /target (OS detail)",
})
expect(
toSessionError(
new ToolFailure({
message: "Tool failed",
error: badArgument({ module: "FileSystem", method: "operation", description: "invalid path" }),
}),
),
).toEqual({
type: "tool.execution",
message: "Tool failed: invalid argument: invalid path",
})
})
test("retries only rate limits, provider-internal failures, and transport failures", () => {
const eligible = [
llm(new RateLimitReason({ message: "rate" })),
+2 -32
View File
@@ -513,16 +513,6 @@ const providerUnavailable = () =>
reason: new TransportReason({ message: "Provider unavailable" }),
})
const incompleteStream = () =>
new AIError({
module: "test",
method: "stream",
reason: new InvalidProviderOutputReason({
classification: "incomplete-stream",
message: "The provider response ended unexpectedly.",
}),
})
const invalidRequest = () =>
new AIError({
module: "test",
@@ -3959,26 +3949,6 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("retries an incomplete stream before output", () =>
Effect.gen(function* () {
const session = yield* setup
yield* admit(session, "Retry incomplete stream")
yield* TestLLM.push(Stream.fail(incompleteStream()))
yield* TestLLM.push(TestLLM.text("Recovered", "incomplete-stream-success"))
const run = yield* session.resume(sessionID).pipe(Effect.forkChild)
yield* TestLLM.wait(1)
yield* TestClock.adjust("2 seconds")
yield* Fiber.join(run)
expect(requests).toHaveLength(2)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user" },
{ type: "assistant", finish: "stop", content: [{ type: "text", text: "Recovered" }] },
])
}),
)
it.effect("uses a larger provider retry-after delay", () =>
Effect.gen(function* () {
const session = yield* setup
@@ -3999,7 +3969,7 @@ describe("SessionRunnerLLM", () => {
it.effect("does not retry eligible failures after observable output", () =>
Effect.gen(function* () {
const session = yield* setup
const failure = incompleteStream()
const failure = rateLimited()
yield* TestLLM.push(
TestLLM.failAfter(
failure,
@@ -4017,7 +3987,7 @@ describe("SessionRunnerLLM", () => {
{
type: "assistant",
finish: "error",
error: { type: "provider.invalid-output" },
error: { type: "provider.rate-limit" },
content: [{ type: "text", text: "Partial" }],
},
])
+25
View File
@@ -257,6 +257,31 @@ describe("ShellTool", () => {
),
)
it.live("reports a missing workdir", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
executeTool(registry, call({ command: cwdCommand, workdir: "missing" })),
).pipe(
Effect.andThen((settled) =>
Effect.sync(() =>
expect(settled).toEqual({
status: "error",
error: {
type: "tool.execution",
message: `Working directory does not exist: ${path.join(tmp.path, "missing")}`,
},
}),
),
),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live("permissions compound commands separately", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),