Skip to content

Commit bdf5a5e

Browse files
committed
refactor(schema): keep session errors extensible
1 parent de9066a commit bdf5a5e

12 files changed

Lines changed: 58 additions & 635 deletions

File tree

packages/client/src/promise/generated/types.ts

Lines changed: 17 additions & 434 deletions
Large diffs are not rendered by default.

packages/core/src/session/runner/llm.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ const layer = Layer.effect(
141141
sessionID,
142142
assistantMessageID: message.id,
143143
callID: tool.id,
144-
error: { type: "tool.stale", message: "Tool execution interrupted", name: tool.name },
144+
error: { type: "tool.stale", message: `Tool execution interrupted: ${tool.name}` },
145145
executed: tool.executed === true,
146146
})
147147
}

packages/core/src/session/runner/publish-llm-event.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input)
391391
callID: event.id,
392392
error:
393393
event.message === `Unknown tool: ${event.name}`
394-
? { type: "tool.unknown", message: event.message, name: event.name }
394+
? { type: "tool.unknown", message: event.message }
395395
: { type: "tool.execution", message: event.message },
396396
executed: tool.providerExecuted,
397397
resultState: providerState(event.providerMetadata),

packages/core/src/session/to-session-error.ts

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,7 @@ export function toSessionError(cause: unknown): SessionError.Error {
1111
if (cause instanceof LLMError) {
1212
switch (cause.reason._tag) {
1313
case "RateLimit":
14-
return {
15-
type: "provider.rate-limit",
16-
message: cause.reason.message,
17-
retryAfterMs: cause.reason.retryAfterMs,
18-
}
14+
return { type: "provider.rate-limit", message: cause.reason.message }
1915
case "Authentication":
2016
return { type: "provider.auth", message: cause.reason.message }
2117
case "QuotaExceeded":
@@ -41,17 +37,12 @@ export function toSessionError(cause: unknown): SessionError.Error {
4137
}
4238
}
4339
if (cause instanceof PermissionV2.DeniedError || cause instanceof PermissionV2.RejectedError)
44-
return {
45-
type: "permission.rejected",
46-
message: cause.message,
47-
permission: cause.permission,
48-
resources: [...cause.resources],
49-
}
50-
if (cause instanceof QuestionV2.RejectedError) return { type: "aborted", message: cause.message, reason: "user" }
40+
return { type: "permission.rejected", message: cause.message }
41+
if (cause instanceof QuestionV2.RejectedError) return { type: "aborted", message: cause.message }
5142
if (cause instanceof ToolFailure)
5243
return cause.error === undefined ? { type: "tool.execution", message: cause.message } : toSessionError(cause.error)
5344
if (cause instanceof StepFailedError) return cause.error
54-
if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message, reason: "user" }
45+
if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message }
5546
if (
5647
cause instanceof SessionRunnerModel.ModelNotSelectedError ||
5748
cause instanceof SessionRunnerModel.ModelUnavailableError ||

packages/core/src/tool/registry.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,13 +64,13 @@ const registryLayer = Layer.effect(
6464
value: advertised ? `Stale tool call: ${input.call.name}` : `Unknown tool: ${input.call.name}`,
6565
},
6666
error: advertised
67-
? ({ type: "tool.stale", message: `Stale tool call: ${input.call.name}`, name: input.call.name } as const)
68-
: ({ type: "tool.unknown", message: `Unknown tool: ${input.call.name}`, name: input.call.name } as const),
67+
? ({ type: "tool.stale", message: `Stale tool call: ${input.call.name}` } as const)
68+
: ({ type: "tool.unknown", message: `Unknown tool: ${input.call.name}` } as const),
6969
}
7070
if (advertised && registration.identity !== advertised)
7171
return {
7272
result: { type: "error" as const, value: `Stale tool call: ${input.call.name}` },
73-
error: { type: "tool.stale" as const, message: `Stale tool call: ${input.call.name}`, name: input.call.name },
73+
error: { type: "tool.stale" as const, message: `Stale tool call: ${input.call.name}` },
7474
}
7575
// Hooks fire only for hosted/local tools; provider-executed calls never reach settleWith.
7676
const beforeEvent: ToolHooks.BeforeEvent = {
@@ -180,7 +180,7 @@ const registryLayer = Layer.effect(
180180
if (registration) return settleWith(input, registration.identity)
181181
return Effect.succeed({
182182
result: { type: "error", value: `Unknown tool: ${input.call.name}` },
183-
error: { type: "tool.unknown", message: `Unknown tool: ${input.call.name}`, name: input.call.name },
183+
error: { type: "tool.unknown", message: `Unknown tool: ${input.call.name}` },
184184
})
185185
},
186186
}

packages/core/test/session-error.test.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,10 @@ import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
2222
const llm = (reason: LLMError["reason"]) => new LLMError({ module: "test", method: "stream", reason })
2323

2424
describe("toSessionError", () => {
25-
test("maps every LLM reason to the closed wire type", () => {
25+
test("maps every LLM reason to the open wire type", () => {
2626
expect(toSessionError(llm(new RateLimitReason({ message: "rate", retryAfterMs: 123 })))).toEqual({
2727
type: "provider.rate-limit",
2828
message: "rate",
29-
retryAfterMs: 123,
3029
})
3130
expect(toSessionError(llm(new AuthenticationReason({ message: "auth", kind: "invalid" }))).type).toBe(
3231
"provider.auth",
@@ -55,19 +54,15 @@ describe("toSessionError", () => {
5554
expect(toSessionError(llm(new UnknownProviderReason({ message: "unknown" }))).type).toBe("provider.unknown")
5655
})
5756

58-
test("preserves structured permission rejection data without inventing resources", () => {
57+
test("preserves the permission rejection type without exposing internal fields", () => {
5958
const rejected = new PermissionV2.RejectedError({ permission: "external_directory", resources: [] })
6059
expect(toSessionError(rejected)).toEqual({
6160
type: "permission.rejected",
6261
message: "Permission rejected: external_directory",
63-
permission: "external_directory",
64-
resources: [],
6562
})
6663
expect(toSessionError(new ToolFailure({ message: rejected.message, error: rejected }))).toEqual({
6764
type: "permission.rejected",
6865
message: "Permission rejected: external_directory",
69-
permission: "external_directory",
70-
resources: [],
7166
})
7267
})
7368

packages/core/test/session-runner.test.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2492,7 +2492,7 @@ describe("SessionRunnerLLM", () => {
24922492
id: "call-interrupted",
24932493
state: {
24942494
status: "error",
2495-
error: { type: "tool.stale", message: "Tool execution interrupted", name: "echo" },
2495+
error: { type: "tool.stale", message: "Tool execution interrupted: echo" },
24962496
},
24972497
},
24982498
],
@@ -2946,8 +2946,6 @@ describe("SessionRunnerLLM", () => {
29462946
error: {
29472947
type: "permission.rejected",
29482948
message: "Permission rejected: edit",
2949-
permission: "edit",
2950-
resources: ["src/index.ts"],
29512949
},
29522950
content: [
29532951
{
@@ -2958,8 +2956,6 @@ describe("SessionRunnerLLM", () => {
29582956
error: {
29592957
type: "permission.rejected",
29602958
message: "Permission rejected: edit",
2961-
permission: "edit",
2962-
resources: ["src/index.ts"],
29632959
},
29642960
},
29652961
},

packages/core/test/tool-question.test.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,6 @@ describe("QuestionTool", () => {
8787
error: {
8888
type: "permission.rejected",
8989
message: "Permission denied: question",
90-
permission: "question",
91-
resources: ["*"],
9290
},
9391
})
9492
expect(capturedInput()).toBeUndefined()
Lines changed: 5 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,72 +1,9 @@
11
export * as SessionError from "./session-error.js"
22

33
import { Schema } from "effect"
4-
import { optional } from "./schema.js"
54

6-
const Message = { message: Schema.String }
7-
8-
const ProviderRateLimit = Schema.Struct({
9-
type: Schema.Literal("provider.rate-limit"),
10-
...Message,
11-
retryAfterMs: Schema.Finite.pipe(optional),
12-
})
13-
const ProviderAuth = Schema.Struct({ type: Schema.Literal("provider.auth"), ...Message })
14-
const ProviderQuota = Schema.Struct({ type: Schema.Literal("provider.quota"), ...Message })
15-
const ProviderContentFilter = Schema.Struct({ type: Schema.Literal("provider.content-filter"), ...Message })
16-
const ProviderTransport = Schema.Struct({ type: Schema.Literal("provider.transport"), ...Message })
17-
const ProviderInternal = Schema.Struct({ type: Schema.Literal("provider.internal"), ...Message })
18-
const ProviderInvalidOutput = Schema.Struct({ type: Schema.Literal("provider.invalid-output"), ...Message })
19-
const ProviderInvalidRequest = Schema.Struct({ type: Schema.Literal("provider.invalid-request"), ...Message })
20-
const ProviderNoRoute = Schema.Struct({ type: Schema.Literal("provider.no-route"), ...Message })
21-
const ProviderUnknown = Schema.Struct({ type: Schema.Literal("provider.unknown"), ...Message })
22-
const PermissionRejected = Schema.Struct({
23-
type: Schema.Literal("permission.rejected"),
24-
...Message,
25-
permission: Schema.String,
26-
resources: Schema.Array(Schema.String),
27-
})
28-
const ToolUnknown = Schema.Struct({ type: Schema.Literal("tool.unknown"), ...Message, name: Schema.String })
29-
const ToolStale = Schema.Struct({
30-
type: Schema.Literal("tool.stale"),
31-
...Message,
32-
name: Schema.String.pipe(optional),
33-
})
34-
const ToolExecution = Schema.Struct({ type: Schema.Literal("tool.execution"), ...Message })
35-
const ToolResultMissing = Schema.Struct({
36-
type: Schema.Literal("tool.result-missing"),
37-
...Message,
38-
callID: Schema.String.pipe(optional),
39-
})
40-
const Aborted = Schema.Struct({
41-
type: Schema.Literal("aborted"),
42-
...Message,
43-
reason: Schema.Literals(["user", "shutdown", "timeout"]).pipe(optional),
44-
})
45-
const Unknown = Schema.Struct({
46-
type: Schema.Literal("unknown"),
47-
...Message,
48-
agent: Schema.String.pipe(optional),
49-
})
50-
51-
export const Error = Schema.Union([
52-
ProviderRateLimit,
53-
ProviderAuth,
54-
ProviderQuota,
55-
ProviderContentFilter,
56-
ProviderTransport,
57-
ProviderInternal,
58-
ProviderInvalidOutput,
59-
ProviderInvalidRequest,
60-
ProviderNoRoute,
61-
ProviderUnknown,
62-
PermissionRejected,
63-
ToolUnknown,
64-
ToolStale,
65-
ToolExecution,
66-
ToolResultMissing,
67-
Aborted,
68-
Unknown,
69-
])
70-
.pipe(Schema.toTaggedUnion("type"))
71-
.annotate({ identifier: "Session.StructuredError" })
72-
export type Error = typeof Error.Type
5+
export interface Error extends Schema.Schema.Type<typeof Error> {}
6+
export const Error = Schema.Struct({
7+
type: Schema.String,
8+
message: Schema.String,
9+
}).annotate({ identifier: "Session.StructuredError" })

packages/schema/test/session-error.test.ts

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,30 +3,17 @@ import { Schema } from "effect"
33
import { LLM, SessionError } from "../src/index.js"
44

55
describe("SessionError", () => {
6-
test("exports one identified closed union", () => {
6+
test("exports one identified open envelope", () => {
77
expect(SessionError.Error.ast.annotations?.identifier).toBe("Session.StructuredError")
88
expect(Object.keys(SessionError).filter((key) => key !== "SessionError")).toEqual(["Error"])
99
})
1010

11-
test("round trips every closed error type through JSON", () => {
11+
test("round trips current and future error types through JSON", () => {
1212
const values: SessionError.Error[] = [
13-
{ type: "provider.rate-limit", message: "Slow down", retryAfterMs: 2_500 },
13+
{ type: "provider.rate-limit", message: "Slow down" },
1414
{ type: "provider.auth", message: "Authentication failed" },
15-
{ type: "provider.quota", message: "Quota exhausted" },
16-
{ type: "provider.content-filter", message: "Response blocked" },
17-
{ type: "provider.transport", message: "Connection failed" },
18-
{ type: "provider.internal", message: "Provider failed" },
19-
{ type: "provider.invalid-output", message: "Malformed response" },
20-
{ type: "provider.invalid-request", message: "Invalid request" },
21-
{ type: "provider.no-route", message: "No route" },
22-
{ type: "provider.unknown", message: "Unknown provider failure" },
23-
{ type: "permission.rejected", message: "Permission rejected", permission: "read", resources: ["a"] },
24-
{ type: "tool.unknown", message: "Unknown tool", name: "missing" },
25-
{ type: "tool.stale", message: "Stale tool", name: "old" },
26-
{ type: "tool.execution", message: "Tool failed" },
27-
{ type: "tool.result-missing", message: "Missing result", callID: "call_1" },
28-
{ type: "aborted", message: "Interrupted", reason: "user" },
29-
{ type: "unknown", message: "Unexpected", agent: "build" },
15+
{ type: "provider.future-condition", message: "A future provider failure" },
16+
{ type: "unknown", message: "Unexpected" },
3017
]
3118
const codec = Schema.fromJsonString(SessionError.Error)
3219

@@ -36,11 +23,19 @@ describe("SessionError", () => {
3623
}
3724
})
3825

39-
test("rejects unknown types and missing messages", () => {
40-
expect(() =>
41-
Schema.decodeUnknownSync(SessionError.Error)({ type: "provider.timeout", message: "Timeout" }),
42-
).toThrow()
26+
test("accepts future fields while exposing only the stable envelope", () => {
27+
expect(
28+
Schema.decodeUnknownSync(SessionError.Error)({
29+
type: "provider.timeout",
30+
message: "Timeout",
31+
retryAfterMs: 2_500,
32+
}),
33+
).toEqual({ type: "provider.timeout", message: "Timeout" })
34+
})
35+
36+
test("rejects missing envelope fields", () => {
4337
expect(() => Schema.decodeUnknownSync(SessionError.Error)({ type: "provider.auth" })).toThrow()
38+
expect(() => Schema.decodeUnknownSync(SessionError.Error)({ message: "Missing type" })).toThrow()
4439
})
4540
})
4641

0 commit comments

Comments
 (0)