Skip to content

Commit c25bcca

Browse files
committed
fix(question): surface schema failures as friendly tool errors (#28438)
Replace `Schema.decodeUnknownSync` with the Effect-returning form so a malformed payload reaching `Question.ask` becomes a "rewrite the input" tool-call error the AI SDK feeds back to the model, instead of an uncaught throw that crashes the assistant turn. Matches the existing wrap-level pattern at `tool/tool.ts:90` so the model receives an identical, actionable error message.
1 parent 4487fbf commit c25bcca

2 files changed

Lines changed: 56 additions & 2 deletions

File tree

‎packages/opencode/src/question/index.ts‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,12 +146,26 @@ export const layer = Layer.effect(
146146
log.info("asking", { id, questions: input.questions.length })
147147

148148
const deferred = yield* Deferred.make<ReadonlyArray<Answer>, RejectedError>()
149-
const info = Schema.decodeUnknownSync(Request)({
149+
// Use the Effect-returning decode so a schema failure surfaces as a
150+
// typed error the tool wrap can turn into a "rewrite the input" tool
151+
// result. The previous `decodeUnknownSync` would throw uncaught, which
152+
// crashed the assistant turn for any payload that slipped past the
153+
// wrap-level validation (#28438).
154+
const info = yield* Schema.decodeUnknownEffect(Request)({
150155
id,
151156
sessionID: input.sessionID,
152157
questions: input.questions,
153158
tool: input.tool,
154-
})
159+
}).pipe(
160+
Effect.mapError(
161+
(error) =>
162+
new Error(
163+
`The question tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`,
164+
{ cause: error },
165+
),
166+
),
167+
Effect.orDie,
168+
)
155169
pending.set(id, { info, deferred })
156170
yield* bus.publish(Event.Asked, info)
157171

‎packages/opencode/test/question/question.test.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -416,6 +416,46 @@ it.live("pending question rejects on instance dispose", () =>
416416
}),
417417
)
418418

419+
// Regression for #28438: when an invalid payload reaches `Question.ask`
420+
// (one that's missing a required field like `question`), the previous
421+
// `Schema.decodeUnknownSync` would throw uncaught and crash the whole
422+
// assistant turn. The fix routes the failure through `Effect.orDie` with a
423+
// "rewrite the input" Error so the surrounding tool wrap can hand it back to
424+
// the model as a tool-call error rather than killing the session.
425+
it.instance(
426+
"ask - invalid payload surfaces as a friendly defect, not a thrown SchemaError",
427+
() =>
428+
Effect.gen(function* () {
429+
const exit = yield* askEffect({
430+
sessionID: SessionID.make("ses_invalid"),
431+
// Cast: bypassing the public type to simulate an upstream caller
432+
// (or a future schema divergence) that lets a missing required
433+
// field reach the decode boundary.
434+
questions: [
435+
{
436+
header: "Pick mode",
437+
options: [
438+
{ label: "A", description: "x" },
439+
{ label: "B", description: "y" },
440+
],
441+
} as unknown as Question.Info,
442+
],
443+
}).pipe(Effect.exit)
444+
445+
expect(Exit.isFailure(exit)).toBe(true)
446+
if (Exit.isFailure(exit)) {
447+
const message = exit.cause.toString()
448+
// Friendly preamble the AI SDK feeds back to the model so it can retry.
449+
expect(message).toContain("invalid arguments")
450+
expect(message).toContain("Please rewrite the input")
451+
// The exact JSON path pinpointing the missing field, so the model
452+
// knows which question and which field to fix.
453+
expect(message).toContain(`["questions"][0]["question"]`)
454+
}
455+
}),
456+
{ git: true },
457+
)
458+
419459
it.live("pending question rejects on instance reload", () =>
420460
Effect.gen(function* () {
421461
const dir = yield* tmpdirScoped({ git: true })

0 commit comments

Comments
 (0)