mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
fix(opencode): forward provider retries instead of swallowing them
OpenCode subproviders (e.g. opencode/kimi-k2.6 on OpenCode Zen) emit session.status:retry events with messages like "Internal server error" when the upstream provider returns 5xx. opencode itself retries indefinitely with backoff and never emits a terminal event for these. Previously the adapter only surfaced retry messages on a small allowlist of "fatal" tokens (insufficient balance, invalid api key, etc.) and silently dropped everything else. The agent appeared hung from the user's perspective — no message, no spinner update, nothing — until the upstream eventually recovered or the user manually interrupted. Forward every session.status:retry as a non-terminal timeline error item so the user can see what opencode is doing, mirroring opencode's own TUI. Drop the fatal-token allowlist: classifying which retries are "really" terminal is opencode's job, and synthesizing turn_failed for ones we guess at is misleading anyway because opencode keeps spending upstream while we tell the user the agent is done. Also a small design pass on the timeline error rendering: - drop the redundant "Agent error" prefix (the message is descriptive) - drop the colored box background (visual weight too high for a retry) - align the icon vertically to the first text line (height+center) - make the message text selectable so users can copy errors
This commit is contained in:
@@ -1810,9 +1810,7 @@ const activityLogStylesheet = StyleSheet.create((theme) => ({
|
||||
successBg: {
|
||||
backgroundColor: "rgba(20, 83, 45, 0.3)",
|
||||
},
|
||||
errorBg: {
|
||||
backgroundColor: "rgba(127, 29, 29, 0.3)",
|
||||
},
|
||||
errorBg: {},
|
||||
artifactBg: {
|
||||
backgroundColor: "rgba(30, 58, 138, 0.4)",
|
||||
},
|
||||
@@ -1827,6 +1825,8 @@ const activityLogStylesheet = StyleSheet.create((theme) => ({
|
||||
},
|
||||
iconContainer: {
|
||||
flexShrink: 0,
|
||||
height: 20,
|
||||
justifyContent: "center",
|
||||
},
|
||||
textContainer: {
|
||||
flex: 1,
|
||||
@@ -1936,7 +1936,9 @@ export const ActivityLog = memo(function ActivityLog({
|
||||
<IconComponent size={16} color={config.color} />
|
||||
</View>
|
||||
<View style={activityLogStylesheet.textContainer}>
|
||||
<Text style={messageTextStyle}>{displayMessage}</Text>
|
||||
<Text style={messageTextStyle} selectable>
|
||||
{displayMessage}
|
||||
</Text>
|
||||
{metadata && (
|
||||
<View style={activityLogStylesheet.detailsRow}>
|
||||
<Text style={activityLogStylesheet.detailsText}>Details</Text>
|
||||
|
||||
@@ -543,10 +543,6 @@ function appendTodoList(
|
||||
return [...state, entry];
|
||||
}
|
||||
|
||||
function formatErrorMessage(message: string): string {
|
||||
return `Agent error\n${message}`;
|
||||
}
|
||||
|
||||
function reduceTimelineToolCall(
|
||||
state: StreamItem[],
|
||||
event: Extract<AgentStreamEventPayload, { type: "timeline" }>,
|
||||
@@ -672,7 +668,7 @@ function reduceTimelineEvent(
|
||||
id: createTimelineId("error", item.message ?? "", timestamp),
|
||||
timestamp,
|
||||
activityType: "error",
|
||||
message: formatErrorMessage(item.message ?? "Unknown error"),
|
||||
message: item.message ?? "Unknown error",
|
||||
};
|
||||
return finalizeActiveThoughts(appendActivityLog(state, activity));
|
||||
}
|
||||
|
||||
@@ -148,31 +148,10 @@ describe("opencode agent error handling (real)", () => {
|
||||
}
|
||||
}, 45_000);
|
||||
|
||||
test("surfaces fatal retry status from zai/glm-5.1 instead of hanging forever", async () => {
|
||||
const client = new OpenCodeAgentClient(pino({ level: "silent" }));
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: process.cwd(),
|
||||
modeId: "build",
|
||||
});
|
||||
|
||||
try {
|
||||
await session.setModel("zai/glm-5.1");
|
||||
|
||||
const events: AgentStreamEvent[] = [];
|
||||
for await (const event of streamSession(session, "Say hello")) {
|
||||
events.push(event);
|
||||
if (isTerminalEvent(event)) break;
|
||||
}
|
||||
|
||||
const terminal = events.find(isTerminalEvent);
|
||||
expect(terminal).toBeDefined();
|
||||
expect(terminal!.type).toBe("turn_failed");
|
||||
expect((terminal!.type === "turn_failed" ? terminal.error : "").toLowerCase()).toMatch(
|
||||
/insufficient balance|resource package|recharge/,
|
||||
);
|
||||
} finally {
|
||||
await session.close().catch(() => undefined);
|
||||
}
|
||||
}, 45_000);
|
||||
// Note: there used to be a real-API test here pinned to zai/glm-5.1's
|
||||
// "insufficient balance" retry. It's been removed because retry behavior is
|
||||
// entirely upstream-dependent — opencode itself decides when to retry, and
|
||||
// OpenCode Zen's quota/availability changes over time. The translation logic
|
||||
// (session.status:retry → timeline error item) is covered by unit tests in
|
||||
// opencode/event-translator.test.ts.
|
||||
});
|
||||
|
||||
@@ -117,18 +117,6 @@ const OPENCODE_HANDLED_BUILTIN_SLASH_COMMANDS: AgentSlashCommand[] = [
|
||||
{ name: "compact", description: "Compact the current session", argumentHint: "" },
|
||||
{ name: "summarize", description: "Compact the current session", argumentHint: "" },
|
||||
];
|
||||
const OPENCODE_FATAL_RETRY_MESSAGE_TOKENS = [
|
||||
"insufficient balance",
|
||||
"no resource package",
|
||||
"please recharge",
|
||||
"invalid api key",
|
||||
"unauthorized",
|
||||
"authentication",
|
||||
"model not found",
|
||||
"unknown model",
|
||||
"does not exist",
|
||||
"unsupported model",
|
||||
] as const;
|
||||
const OPENCODE_HEADERS_TIMEOUT_TOKENS = [
|
||||
"headers timeout",
|
||||
"headers timeout error",
|
||||
@@ -329,14 +317,6 @@ async function reconcileOpenCodeSessionClose(params: {
|
||||
}
|
||||
}
|
||||
|
||||
function isFatalOpenCodeRetryMessage(message: string | null | undefined): boolean {
|
||||
const normalized = typeof message === "string" ? message.trim().toLowerCase() : "";
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
return OPENCODE_FATAL_RETRY_MESSAGE_TOKENS.some((token) => normalized.includes(token));
|
||||
}
|
||||
|
||||
function isOpenCodeHeadersTimeoutFailure(error: unknown): boolean {
|
||||
const diagnostics = new Set<string>();
|
||||
const queue: unknown[] = [error];
|
||||
@@ -2161,15 +2141,24 @@ function appendOpenCodeSessionStatus(
|
||||
events.push({ type: "turn_completed", provider: "opencode", usage: undefined });
|
||||
return;
|
||||
}
|
||||
if (status.type === "retry" && isFatalOpenCodeRetryMessage(status.message)) {
|
||||
resetOpenCodeTurnTrackingState(state);
|
||||
if (status.type === "retry") {
|
||||
// Mirror what opencode's TUI shows: retry attempts are visible activity, not
|
||||
// terminal. opencode itself never gives up — it backs off and tries again
|
||||
// forever. If we silently swallow these the user sees a spinner with no
|
||||
// explanation. Forwarding as a timeline error item is a no-op for old
|
||||
// clients (the schema already supports it).
|
||||
const message = typeof status.message === "string" ? status.message.trim() : "";
|
||||
const text = message
|
||||
? `Provider retry (attempt ${status.attempt}): ${message}`
|
||||
: `Provider retry (attempt ${status.attempt})`;
|
||||
events.push({
|
||||
type: "turn_failed",
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
error: toDiagnosticErrorMessage(status.message),
|
||||
item: { type: "error", message: text },
|
||||
});
|
||||
return;
|
||||
}
|
||||
// "retry" and "busy" are transient — no terminal event.
|
||||
// "busy" is transient — no terminal event, no surfaced activity.
|
||||
}
|
||||
|
||||
interface Deferred<T> {
|
||||
|
||||
@@ -735,7 +735,7 @@ describe("translateOpenCodeEvent", () => {
|
||||
expect(state.partTypes.size).toBe(0);
|
||||
});
|
||||
|
||||
it("emits turn_failed from fatal session.status retry", () => {
|
||||
it("forwards session.status retry as a non-terminal timeline error item", () => {
|
||||
const state = createState();
|
||||
state.streamedPartKeys.add("text:part-1");
|
||||
state.partTypes.set("part-1", "text");
|
||||
@@ -747,8 +747,8 @@ describe("translateOpenCodeEvent", () => {
|
||||
sessionID: "session-1",
|
||||
status: {
|
||||
type: "retry",
|
||||
attempt: 2,
|
||||
message: "Invalid API key",
|
||||
attempt: 3,
|
||||
message: "Internal server error",
|
||||
next: Date.now() + 1000,
|
||||
},
|
||||
},
|
||||
@@ -758,16 +758,46 @@ describe("translateOpenCodeEvent", () => {
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: "turn_failed",
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
error: "Invalid API key",
|
||||
item: { type: "error", message: "Provider retry (attempt 3): Internal server error" },
|
||||
},
|
||||
]);
|
||||
expect(state.streamedPartKeys.size).toBe(0);
|
||||
expect(state.partTypes.size).toBe(0);
|
||||
// Streaming state must NOT be reset — the turn is still alive, opencode
|
||||
// will eventually either succeed or emit session.idle / session.error.
|
||||
expect(state.streamedPartKeys.size).toBe(1);
|
||||
expect(state.partTypes.size).toBe(1);
|
||||
});
|
||||
|
||||
it("ignores transient session.status updates", () => {
|
||||
it("forwards retry without a message using just the attempt number", () => {
|
||||
const state = createState();
|
||||
|
||||
const result = translateOpenCodeEvent(
|
||||
{
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
status: {
|
||||
type: "retry",
|
||||
attempt: 1,
|
||||
message: "",
|
||||
next: Date.now() + 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: "timeline",
|
||||
provider: "opencode",
|
||||
item: { type: "error", message: "Provider retry (attempt 1)" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores transient session.status busy updates", () => {
|
||||
const state = createState();
|
||||
|
||||
const busy = translateOpenCodeEvent(
|
||||
@@ -781,24 +811,7 @@ describe("translateOpenCodeEvent", () => {
|
||||
state,
|
||||
);
|
||||
|
||||
const retry = translateOpenCodeEvent(
|
||||
{
|
||||
type: "session.status",
|
||||
properties: {
|
||||
sessionID: "session-1",
|
||||
status: {
|
||||
type: "retry",
|
||||
attempt: 1,
|
||||
message: "rate limited",
|
||||
next: Date.now() + 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
expect(busy).toEqual([]);
|
||||
expect(retry).toEqual([]);
|
||||
});
|
||||
|
||||
it("emits structured assistant output when schema mode completes without text parts", () => {
|
||||
|
||||
Reference in New Issue
Block a user