Skip to content

Commit 8ad437f

Browse files
LIU9293Kai Liu
andauthored
fix(opencode): surface provider APIErrors instead of silent _Done_ (#205)
* fix(opencode): surface provider APIErrors instead of silent _Done_ The OpenCode SDK returns HTTP 200 even when the underlying provider call failed: the failure is stashed on `data.info.error` (e.g. Anthropic APIError when an image in session history exceeds 2000px) and the assistant message ends up with zero text parts. The adapter only looked at the top-level `result.error`, so the kernel silently fell back to `_Done_` and hid the real failure from the user. - Detect `data.info.error` on prompt responses and throw with the provider error name, status code, and message. - When the error is the Anthropic "image dimensions exceed max allowed size" case, best-effort `session.revert` the poisoned turn so the thread is not permanently stuck failing every subsequent prompt. * chore: bump version to 0.1.37 * fix(opencode): treat MessageAbortedError as non-fatal and fire-and-forget revert Addresses Codex review on PR #205: - P1: skip throw when info.error.name is MessageAbortedError so a user's stop does not race ahead of the kernel's graceful stop path and flip the run into a failed state. - P2: dispatch tryRevertOversizedImageTurn asynchronously instead of awaiting it, so a slow/hung revert cannot delay surfacing the original provider error to the user. --------- Co-authored-by: Kai Liu <kailiu@mmini.tail90d2d5.ts.net>
1 parent 2e62f41 commit 8ad437f

3 files changed

Lines changed: 212 additions & 2 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ode",
3-
"version": "0.1.36",
3+
"version": "0.1.37",
44
"description": "Coding anywhere with your coding agents connected",
55
"module": "packages/core/index.ts",
66
"type": "module",

packages/agents/opencode/client.ts

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,110 @@ export function buildOpenCodeCommand(
4343
return formatShellCommand(args);
4444
}
4545

46+
/**
47+
* Shape of the OpenCode assistant-message `error` field. Matches the SDK's
48+
* `ApiError | ProviderAuthError | UnknownError | MessageOutputLengthError |
49+
* MessageAbortedError` union (see @opencode-ai/sdk types).
50+
*/
51+
type OpenCodeInfoError = {
52+
name?: string;
53+
data?: {
54+
message?: string;
55+
statusCode?: number;
56+
providerID?: string;
57+
responseBody?: string;
58+
[key: string]: unknown;
59+
};
60+
};
61+
62+
export function extractInfoError(data: Record<string, unknown>): OpenCodeInfoError | null {
63+
const info = data.info as Record<string, unknown> | undefined;
64+
const error = info?.error;
65+
if (!error || typeof error !== "object") return null;
66+
return error as OpenCodeInfoError;
67+
}
68+
69+
export function formatInfoError(error: OpenCodeInfoError): string {
70+
const name = error.name ?? "UnknownError";
71+
const message = error.data?.message;
72+
const statusCode = error.data?.statusCode;
73+
const statusSuffix = statusCode ? ` (status ${statusCode})` : "";
74+
if (message) {
75+
return `OpenCode ${name}${statusSuffix}: ${message}`;
76+
}
77+
return `OpenCode ${name}${statusSuffix}`;
78+
}
79+
80+
/**
81+
* `MessageAbortedError` is produced by the OpenCode server when a run is
82+
* cancelled (e.g. the user typed `stop` and the kernel called
83+
* `session.abort`). It is a normal, non-fatal outcome: the kernel's stop
84+
* path at packages/core/kernel/request-run.ts is responsible for publishing
85+
* the final text. Treat it as a soft failure so we don't race ahead of the
86+
* stop handling with a failed-run status.
87+
*/
88+
export function isAbortError(error: OpenCodeInfoError): boolean {
89+
return error.name === "MessageAbortedError";
90+
}
91+
92+
/**
93+
* Detect the Anthropic "image dimensions exceed max allowed size" error. A
94+
* single oversized screenshot stuck in session history would otherwise break
95+
* every subsequent turn in the thread, because the full history is replayed
96+
* on every prompt. See thread `C0AUUDD0VDX:1776966674.077239` for the
97+
* original report.
98+
*/
99+
export function isOversizedImageError(error: OpenCodeInfoError): boolean {
100+
if (error.name !== "APIError") return false;
101+
const haystacks: string[] = [];
102+
if (typeof error.data?.message === "string") haystacks.push(error.data.message);
103+
if (typeof error.data?.responseBody === "string") haystacks.push(error.data.responseBody);
104+
const blob = haystacks.join(" ").toLowerCase();
105+
if (!blob) return false;
106+
return (
107+
blob.includes("image dimensions exceed") ||
108+
blob.includes("2000 pixels") ||
109+
blob.includes("many-image requests")
110+
);
111+
}
112+
113+
async function tryRevertOversizedImageTurn(
114+
client: Awaited<ReturnType<typeof getSessionClient>>,
115+
sessionId: string,
116+
data: Record<string, unknown>,
117+
directory: string
118+
): Promise<void> {
119+
try {
120+
const info = data.info as { id?: string; parentID?: string } | undefined;
121+
// Prefer reverting to the parent user message so the failed assistant
122+
// turn plus its oversized-image tool output are removed; fall back to
123+
// the assistant message id if parent is missing.
124+
const messageID = info?.parentID ?? info?.id;
125+
if (!messageID) {
126+
log.warn("Oversized-image error detected but could not locate message id to revert", {
127+
sessionId,
128+
});
129+
return;
130+
}
131+
log.warn("Reverting session turn that triggered oversized-image APIError", {
132+
sessionId,
133+
messageID,
134+
});
135+
await client.session.revert({
136+
sessionID: sessionId,
137+
messageID,
138+
directory: directory || undefined,
139+
});
140+
} catch (err) {
141+
// Revert is best-effort: if it fails we still want the original API
142+
// error to surface to the user rather than swallowing it here.
143+
log.warn("Failed to revert session after oversized-image APIError", {
144+
sessionId,
145+
error: String(err),
146+
});
147+
}
148+
}
149+
46150
export async function createSession(
47151
workingPath: string,
48152
env?: SessionEnvironment
@@ -188,9 +292,32 @@ export async function sendMessage(
188292
throw new Error("OpenCode returned empty response");
189293
}
190294

295+
// The SDK returns 200 OK even when the underlying provider call failed:
296+
// the failure is stashed on `data.info.error` (ApiError, ProviderAuthError, ...)
297+
// and the assistant message ends up with zero text parts. Without this check
298+
// the kernel would silently fall back to "_Done_" (see
299+
// packages/core/kernel/request-run.ts:847) and hide the real failure from
300+
// the user.
301+
const data = result.data as Record<string, unknown>;
302+
const infoError = extractInfoError(data);
303+
if (infoError && !isAbortError(infoError)) {
304+
// If the error was caused by an oversized image in the session
305+
// history, try to revert the poisoned turn so subsequent messages
306+
// in this thread are not permanently broken. Fire-and-forget: we
307+
// do NOT await here, so a slow/hung revert cannot delay the user
308+
// from seeing the original API failure.
309+
if (isOversizedImageError(infoError)) {
310+
void tryRevertOversizedImageTurn(client, activeSessionId, data, workingPath);
311+
}
312+
throw new Error(formatInfoError(infoError));
313+
}
314+
// MessageAbortedError falls through: the assistant message is empty
315+
// because the user stopped the run. The kernel's stop path handles
316+
// this gracefully, so surfacing it as a thrown error here would just
317+
// race the stop handler and flip the run into a "failed" state.
318+
191319
// Extract text from response in a few known shapes.
192320
const messages: OpenCodeMessage[] = [];
193-
const data = result.data as Record<string, unknown>;
194321

195322
const pushText = (value: unknown): void => {
196323
if (typeof value !== "string") return;
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { describe, expect, it } from "bun:test";
2+
import {
3+
extractInfoError,
4+
formatInfoError,
5+
isAbortError,
6+
isOversizedImageError,
7+
} from "../opencode/client";
8+
9+
describe("opencode info-error detection", () => {
10+
it("returns null when no error is present", () => {
11+
expect(extractInfoError({})).toBeNull();
12+
expect(extractInfoError({ info: {} })).toBeNull();
13+
expect(extractInfoError({ info: { error: null } })).toBeNull();
14+
});
15+
16+
it("extracts an APIError from info.error", () => {
17+
const err = extractInfoError({
18+
info: {
19+
error: {
20+
name: "APIError",
21+
data: { message: "boom", statusCode: 400, isRetryable: false },
22+
},
23+
},
24+
});
25+
expect(err).toEqual({
26+
name: "APIError",
27+
data: { message: "boom", statusCode: 400, isRetryable: false },
28+
});
29+
});
30+
31+
it("formats info errors with name, status code, and message", () => {
32+
expect(
33+
formatInfoError({
34+
name: "APIError",
35+
data: { message: "quota exceeded", statusCode: 429 },
36+
})
37+
).toBe("OpenCode APIError (status 429): quota exceeded");
38+
});
39+
40+
it("formats info errors that are missing a message", () => {
41+
expect(formatInfoError({ name: "UnknownError", data: {} })).toBe("OpenCode UnknownError");
42+
});
43+
44+
it("detects the oversized-image Anthropic APIError", () => {
45+
expect(
46+
isOversizedImageError({
47+
name: "APIError",
48+
data: {
49+
message:
50+
"messages.9.content.18.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels",
51+
statusCode: 400,
52+
},
53+
})
54+
).toBe(true);
55+
});
56+
57+
it("does not misclassify unrelated APIErrors as image errors", () => {
58+
expect(
59+
isOversizedImageError({
60+
name: "APIError",
61+
data: { message: "rate limit reached", statusCode: 429 },
62+
})
63+
).toBe(false);
64+
});
65+
66+
it("does not treat non-API errors as image errors", () => {
67+
expect(
68+
isOversizedImageError({
69+
name: "ProviderAuthError",
70+
data: { message: "image dimensions exceed 2000 pixels" },
71+
})
72+
).toBe(false);
73+
});
74+
75+
it("flags MessageAbortedError as a non-fatal abort", () => {
76+
expect(isAbortError({ name: "MessageAbortedError", data: {} })).toBe(true);
77+
});
78+
79+
it("does not flag provider failures as aborts", () => {
80+
expect(isAbortError({ name: "APIError", data: { message: "boom" } })).toBe(false);
81+
expect(isAbortError({ name: "ProviderAuthError", data: {} })).toBe(false);
82+
});
83+
});

0 commit comments

Comments
 (0)