summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2026-04-18 10:35:25 -0400
committerDax Raad <[email protected]>2026-04-18 10:38:35 -0400
commit882b8e1e7587c4b24e5cb7ee9409e93b9455c5b0 (patch)
tree98428b1bdbf40d931efed272327c398bdb1927a1
parent95edbc0ae68a5e4f9a24a5d0249391ef18b168f5 (diff)
downloadopencode-882b8e1e7587c4b24e5cb7ee9409e93b9455c5b0.tar.gz
opencode-882b8e1e7587c4b24e5cb7ee9409e93b9455c5b0.zip
core: track retry attempts with detailed error context on assistant entries
users can now see when transient failures occur during assistant responses, such as rate limits or provider overloads, giving visibility into what issues were encountered and automatically resolved before the final response
-rw-r--r--packages/opencode/src/v2/session-entry-stepper.ts12
-rw-r--r--packages/opencode/src/v2/session-entry.ts20
-rw-r--r--packages/opencode/src/v2/session-event.ts15
-rw-r--r--packages/opencode/test/session/session-entry-stepper.test.ts87
4 files changed, 130 insertions, 4 deletions
diff --git a/packages/opencode/src/v2/session-entry-stepper.ts b/packages/opencode/src/v2/session-entry-stepper.ts
index 3d642579d..3fe4266c0 100644
--- a/packages/opencode/src/v2/session-entry-stepper.ts
+++ b/packages/opencode/src/v2/session-entry-stepper.ts
@@ -1,4 +1,4 @@
-import { castDraft, produce, type WritableDraft } from "immer"
+import { produce, type WritableDraft } from "immer"
import { SessionEvent } from "./session-event"
import { SessionEntry } from "./session-entry"
@@ -235,7 +235,15 @@ export function stepWith<Result>(adapter: Adapter<Result>, event: SessionEvent.E
)
}
},
- retried: () => {},
+ retried: (event) => {
+ if (currentAssistant) {
+ adapter.updateAssistant(
+ produce(currentAssistant, (draft) => {
+ draft.retries = [...(draft.retries ?? []), SessionEntry.AssistantRetry.fromEvent(event)]
+ }),
+ )
+ }
+ },
compacted: (event) => {
adapter.appendEntry(SessionEntry.Compaction.fromEvent(event))
},
diff --git a/packages/opencode/src/v2/session-entry.ts b/packages/opencode/src/v2/session-entry.ts
index 97c5fc7ce..b261d8b5b 100644
--- a/packages/opencode/src/v2/session-entry.ts
+++ b/packages/opencode/src/v2/session-entry.ts
@@ -104,6 +104,24 @@ export class AssistantReasoning extends Schema.Class<AssistantReasoning>("Sessio
text: Schema.String,
}) {}
+export class AssistantRetry extends Schema.Class<AssistantRetry>("Session.Entry.Assistant.Retry")({
+ attempt: Schema.Number,
+ error: SessionEvent.RetryError,
+ time: Schema.Struct({
+ created: Schema.DateTimeUtc,
+ }),
+}) {
+ static fromEvent(event: SessionEvent.Retried) {
+ return new AssistantRetry({
+ attempt: event.attempt,
+ error: event.error,
+ time: {
+ created: event.timestamp,
+ },
+ })
+ }
+}
+
export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe(
Schema.toTaggedUnion("type"),
)
@@ -113,6 +131,7 @@ export class Assistant extends Schema.Class<Assistant>("Session.Entry.Assistant"
...Base,
type: Schema.Literal("assistant"),
content: AssistantContent.pipe(Schema.Array),
+ retries: AssistantRetry.pipe(Schema.Array, Schema.optional),
cost: Schema.Number.pipe(Schema.optional),
tokens: Schema.Struct({
input: Schema.Number,
@@ -137,6 +156,7 @@ export class Assistant extends Schema.Class<Assistant>("Session.Entry.Assistant"
created: event.timestamp,
},
content: [],
+ retries: [],
})
}
}
diff --git a/packages/opencode/src/v2/session-event.ts b/packages/opencode/src/v2/session-event.ts
index 11d4a5db2..f922becf3 100644
--- a/packages/opencode/src/v2/session-event.ts
+++ b/packages/opencode/src/v2/session-event.ts
@@ -53,6 +53,15 @@ export namespace SessionEvent {
source: Source.pipe(Schema.optional),
}) {}
+ export class RetryError extends Schema.Class<RetryError>("Session.Event.Retry.Error")({
+ message: Schema.String,
+ statusCode: Schema.Number.pipe(Schema.optional),
+ isRetryable: Schema.Boolean,
+ responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
+ responseBody: Schema.String.pipe(Schema.optional),
+ metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
+ }) {}
+
export class Prompt extends Schema.Class<Prompt>("Session.Event.Prompt")({
...Base,
type: Schema.Literal("prompt"),
@@ -386,14 +395,16 @@ export namespace SessionEvent {
export class Retried extends Schema.Class<Retried>("Session.Event.Retried")({
...Base,
type: Schema.Literal("retried"),
- error: Schema.String,
+ attempt: Schema.Number,
+ error: RetryError,
}) {
- static create(input: BaseInput & { error: string }) {
+ static create(input: BaseInput & { attempt: number; error: RetryError }) {
return new Retried({
id: input.id ?? ID.create(),
type: "retried",
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
metadata: input.metadata,
+ attempt: input.attempt,
error: input.error,
})
}
diff --git a/packages/opencode/test/session/session-entry-stepper.test.ts b/packages/opencode/test/session/session-entry-stepper.test.ts
index 5c7df2dba..32036cb1e 100644
--- a/packages/opencode/test/session/session-entry-stepper.test.ts
+++ b/packages/opencode/test/session/session-entry-stepper.test.ts
@@ -27,6 +27,24 @@ function assistant() {
type: "assistant",
time: { created: time(0) },
content: [],
+ retries: [],
+ })
+}
+
+function retryError(message: string) {
+ return new SessionEvent.RetryError({
+ message,
+ isRetryable: true,
+ })
+}
+
+function retry(attempt: number, message: string, created: number) {
+ return new SessionEntry.AssistantRetry({
+ attempt,
+ error: retryError(message),
+ time: {
+ created: time(created),
+ },
})
}
@@ -78,6 +96,12 @@ function tool(state: SessionEntryStepper.MemoryState, callID: string) {
return tools(state).find((x) => x.callID === callID)
}
+function retriesOf(state: SessionEntryStepper.MemoryState) {
+ const entry = last(state)
+ if (!entry) return []
+ return entry.retries ?? []
+}
+
function adapterStore() {
return {
committed: [] as SessionEntry.Entry[],
@@ -168,6 +192,33 @@ describe("session-entry-stepper", () => {
])
expect(store.committed[0].time.completed).toEqual(time(7))
})
+
+ test("aggregates retry events onto the current assistant", () => {
+ const store = adapterStore()
+ store.committed.push(assistant())
+
+ SessionEntryStepper.stepWith(
+ adapterFor(store),
+ SessionEvent.Retried.create({
+ attempt: 1,
+ error: retryError("rate limited"),
+ timestamp: time(1),
+ }),
+ )
+ SessionEntryStepper.stepWith(
+ adapterFor(store),
+ SessionEvent.Retried.create({
+ attempt: 2,
+ error: retryError("provider overloaded"),
+ timestamp: time(2),
+ }),
+ )
+
+ expect(store.committed[0]?.type).toBe("assistant")
+ if (store.committed[0]?.type !== "assistant") return
+
+ expect(store.committed[0].retries).toEqual([retry(1, "rate limited", 1), retry(2, "provider overloaded", 2)])
+ })
})
describe("memory", () => {
@@ -231,6 +282,21 @@ describe("session-entry-stepper", () => {
expect(reasons(state)).toEqual([{ type: "reasoning", text: "final" }])
})
+
+ test("stepWith through memory records retries", () => {
+ const state = active()
+
+ SessionEntryStepper.stepWith(
+ SessionEntryStepper.memory(state),
+ SessionEvent.Retried.create({
+ attempt: 1,
+ error: retryError("rate limited"),
+ timestamp: time(1),
+ }),
+ )
+
+ expect(retriesOf(state)).toEqual([retry(1, "rate limited", 1)])
+ })
})
describe("step", () => {
@@ -481,6 +547,27 @@ describe("session-entry-stepper", () => {
})
})
+ test("records retries on the pending assistant", () => {
+ const next = run(
+ [
+ SessionEvent.Retried.create({
+ attempt: 1,
+ error: retryError("rate limited"),
+ timestamp: time(1),
+ }),
+ SessionEvent.Retried.create({
+ attempt: 2,
+ error: retryError("provider overloaded"),
+ timestamp: time(2),
+ }),
+ ],
+ active(),
+ )
+
+ expect(retriesOf(next)).toEqual([retry(1, "rate limited", 1), retry(2, "provider overloaded", 2)])
+ })
+ })
+
describe("known reducer gaps", () => {
test("prompt appends immutably when no assistant is pending", () => {
FastCheck.assert(