diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 94182f1a27..38acf58e8f 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -146,12 +146,26 @@ export const layer = Layer.effect( log.info("asking", { id, questions: input.questions.length }) const deferred = yield* Deferred.make, RejectedError>() - const info = Schema.decodeUnknownSync(Request)({ + // Use the Effect-returning decode so a schema failure surfaces as a + // typed error the tool wrap can turn into a "rewrite the input" tool + // result. The previous `decodeUnknownSync` would throw uncaught, which + // crashed the assistant turn for any payload that slipped past the + // wrap-level validation (#28438). + const info = yield* Schema.decodeUnknownEffect(Request)({ id, sessionID: input.sessionID, questions: input.questions, tool: input.tool, - }) + }).pipe( + Effect.mapError( + (error) => + new Error( + `The question tool was called with invalid arguments: ${error}.\nPlease rewrite the input so it satisfies the expected schema.`, + { cause: error }, + ), + ), + Effect.orDie, + ) pending.set(id, { info, deferred }) yield* bus.publish(Event.Asked, info) diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts index a5841bd08d..09ede40068 100644 --- a/packages/opencode/test/question/question.test.ts +++ b/packages/opencode/test/question/question.test.ts @@ -416,6 +416,46 @@ it.live("pending question rejects on instance dispose", () => }), ) +// Regression for #28438: when an invalid payload reaches `Question.ask` +// (one that's missing a required field like `question`), the previous +// `Schema.decodeUnknownSync` would throw uncaught and crash the whole +// assistant turn. The fix routes the failure through `Effect.orDie` with a +// "rewrite the input" Error so the surrounding tool wrap can hand it back to +// the model as a tool-call error rather than killing the session. +it.instance( + "ask - invalid payload surfaces as a friendly defect, not a thrown SchemaError", + () => + Effect.gen(function* () { + const exit = yield* askEffect({ + sessionID: SessionID.make("ses_invalid"), + // Cast: bypassing the public type to simulate an upstream caller + // (or a future schema divergence) that lets a missing required + // field reach the decode boundary. + questions: [ + { + header: "Pick mode", + options: [ + { label: "A", description: "x" }, + { label: "B", description: "y" }, + ], + } as unknown as Question.Info, + ], + }).pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const message = exit.cause.toString() + // Friendly preamble the AI SDK feeds back to the model so it can retry. + expect(message).toContain("invalid arguments") + expect(message).toContain("Please rewrite the input") + // The exact JSON path pinpointing the missing field, so the model + // knows which question and which field to fix. + expect(message).toContain(`["questions"][0]["question"]`) + } + }), + { git: true }, +) + it.live("pending question rejects on instance reload", () => Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true })