Skip to content

question tool: SchemaError "Missing key at [\"questions\"][0][\"question\"]" — required field should be optional / fall back to header #28438

Closed

Description

@dawidbednarczyk

Summary

The AskUserQuestion / question tool fails synchronously with

SchemaError(Missing key at ["questions"][0]["question"])

when the model returns a question item that includes header + options but omits the inner question (long-form prompt) field. The tool call wrapper does not catch the throw, so the whole assistant turn errors out and the session becomes unusable until restarted.

This is reproducible against v1.14.50 and the schema is unchanged in v1.14.49 and v1.14.51, so upgrading does not help. The lax-decode fix in 397ee419d (~Jan 2026) is post-window.

Reproduction

Any prompt template / model that emits a question item like:

{
  "header": "Pick mode",
  "options": [
    {"label": "B full", "description": "~5.6h"},
    {"label": "C diag", "description": "diagnostic only"}
  ]
}

…will trigger the throw at decode time. Observed in real sessions against two unrelated projects (kestra-software-reports, agent-installation-troubleshooting) on v1.14.50.

Root cause

packages/opencode/src/question/index.ts:23 declares question as required:

const base = {
  question: Schema.String.annotate({ description: "Complete question" }),  // ← REQUIRED
  header:   Schema.String.annotate({ description: "Very short label (max 30 chars)" }),
  options:  Schema.Array(Option).annotate({ description: "Available choices" }),
  multiple: Schema.optional(Schema.Boolean).annotate({ description: "Allow selecting multiple choices" }),
}

Decode site (synchronous, uncaught):

// packages/opencode/src/question/index.ts:149
const info = Schema.decodeUnknownSync(Request)({ id, sessionID, questions: input.questions, tool: input.tool })

Schema.decodeUnknownSync throws immediately on the missing key. The tool dispatcher does not wrap this in a try/catch, so the assistant turn ends with an unhandled SchemaError.

Diff window

$ git log --oneline v1.14.49..v1.14.51 -- packages/opencode/src/question/
(no commits)

Schema is byte-identical across the range. No upstream PR addresses this.

Proposed fix (two-line patch)

- question: Schema.String.annotate({ description: "Complete question" }),
+ question: Schema.optional(Schema.String).annotate({ description: "Complete question (defaults to header if omitted)" }),

…plus a one-line renderer fallback in packages/opencode/src/cli/cmd/tui/routes/session/question.tsx to display question ?? header.

This matches the lax-decode direction already taken in 397ee419d (later commit) and is structurally non-breaking (Effect Schema Schema.optional is a pure relaxation).

Why not just fix the model prompt?

The user has no control over which model emits the request, and the symptom appears across multiple model backends. A two-line server-side relaxation is the right defense-in-depth — the inner question is genuinely redundant when header already carries the prompt.

Environment

  • opencode v1.14.50
  • macOS 14, tmux 3.4
  • Encountered in 2 independent sessions in the same week

Related

  • Lax-decode commit 397ee419d (post-window) shows the maintainers' direction
  • No existing issue with text SchemaError question was found at time of filing

Activity

  1. 21pounder commented on May 20, 2026

    @21pounder
    Contributor

    I looked into this and can confirm the bug. Here's what I found:

    Root cause

    Question.Prompt inherits from base in packages/opencode/src/question/index.ts, where question is declared as Schema.String (required). When the tool is invoked by the model without the question field, Schema.decodeUnknownSync throws synchronously at line 149 — uncaught — crashing the assistant turn.

    I reproduced it locally with a minimal script:

    import { Schema } from "effect"
    import { Question } from "./src/question"
    
    Schema.decodeUnknownSync(Question.Prompt)({
      header: "Pick mode",
      options: [
        { label: "Full build", description: "~5.6h" },
        { label: "Diagnostic", description: "diagnostic only" },
      ],
    })

    Output:

    error: Missing key
      at ["question"]
    

    There's also a second affected site

    packages/opencode/src/tool/question.ts:31 formats the output string using q.question directly:

    .map((q, i) => `"${q.question}"="${answers[i]?.length ? answers[i].join(", ") : "Unanswered"}"`)

    If question were made optional, this would render "undefined"=... in the tool result. It should fall back to q.header.

    And a third: the TUI renderer

    packages/opencode/src/cli/cmd/tui/routes/session/question.tsx:348 renders {question()?.question} — if question is undefined, the prompt text is silently blank.

    Proposed fix (3 lines across 3 files)

    1. question/index.ts — make question optional in base:
    - question: Schema.String.annotate({ description: "Complete question" }),
    + question: Schema.optional(Schema.String).annotate({ description: "Complete question" }),
    1. tool/question.ts:31 — fall back to header in the formatted output:
    - .map((q, i) => `"${q.question}"=...`)
    + .map((q, i) => `"${q.question ?? q.header}"=...`)
    1. tui/routes/session/question.tsx:348 — fall back to header in the renderer:
    - {question()?.question}
    + {question()?.question ?? question()?.header}

    I'd like to take this if it's not already being worked on.

    Screenshots

    Image
  2. wanggan768q commented on May 20, 2026

    @wanggan768q

    +1

    Same root cause, mirror direction — got Missing key at ["questions"][0]["header"]
    on GPT-5.5 (works fine on claude-opus-4-7). The proposed two-line
    Schema.optional
    relaxation should be applied symmetrically to header as well, not just
    question.

  3. 21pounder commented on May 21, 2026

    @21pounder
    Contributor

    Thanks for the cleaner approach @kitlangton — hardening the decode boundary is the right call. Happy to close this in favor of #28563.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions