Skip to content

bug(session): legacy message/part rows missing required schema fields cause 400 BadRequest on session load #29908

@grenaad

Description

@grenaad

Summary

Loading sessions in the opencode desktop app fails with 400 BadRequest "Missing key at [0]['info']['agent']" (and other variants pointing at different paths inside the message/part response) for any user whose opencode.db predates one of several schema-tightening PRs from Q3–Q4 2025. Once one bad row exists in a session, the whole GET /session/:sessionID/message response 400s during Effect HttpApi response encoding, and that session becomes unloadable.

Root cause (suspected): several fields were made required on the v2 message/part Effect Schemas over time, but no corresponding data migration was ever written to backfill those fields on already-stored rows. The read path returns stored JSON verbatim, and the strict HTTP response schema then rejects it.

Reproduction

Any user with an opencode.db containing messages written before ~Dec 14 2025 is at risk. In our case the file at ~/.local/share/opencode/opencode.db (~4 GB, sessions back to mid-2025) had:

  • 54,051 Assistant rows missing agent
  • 21,839 Assistant rows missing parentID
  • 243 Assistant rows missing mode
  • 7,333 User rows missing agent
  • 7,333 User rows missing model (the whole {providerID, modelID} object)
  • 51,805 step-finish parts missing reason
  • 427 / 423 completed tool parts missing state.metadata / state.title
  • 14 compaction parts missing auto
  • 4 completed tool parts missing state.time.start / state.time.end
  • 1 stuck pending tool part missing state.input / state.raw

Loading any session containing one of these rows triggers a 400 from the HTTP endpoint defined at packages/opencode/src/server/routes/instance/httpapi/groups/session.ts:175 (success schema is Schema.Array(MessageV2.WithParts)), surfaced to the desktop renderer through the SDK's wrapClientError in packages/sdk/js/src/error-interceptor.ts.

Example renderer stack trace:

Error: Missing key
  at [0]["info"]["agent"]
    at wrapClientError (oc://renderer/assets/main-CNUUp_VN.js:63935:12)
    at request (oc://renderer/assets/main-CNUUp_VN.js:60173:28)
    at async retry (oc://renderer/assets/main-CNUUp_VN.js:64594:14)
    at async fetchMessages (oc://renderer/assets/main-CNUUp_VN.js:66019:22)
    at async loadMessages (oc://renderer/assets/main-CNUUp_VN.js:66038:5)
{
  "body": { "name": "BadRequest", "data": { "message": "Missing key\n  at [0][\"info\"][\"agent\"]", "kind": "Body" } },
  "status": 400
}

The second error surfaced after we backfilled the first field, pointing at a different missing key:

Error: Missing key
  at [1]["parts"][3]["reason"]

Root cause analysis

The User / Assistant schemas in packages/opencode/src/session/message-v2.ts and the various Part variants declare these fields as required (Schema.String, Schema.Finite, etc.), but the read path at message-v2.ts:580-593 (info(row) / part(row)) splats row.data and casts to Info / Part with no validation or defaulting:

const info = (row) => ({ ...row.data, id: row.id, sessionID: row.session_id }) as Info

So any legacy row whose JSON blob predates the field becoming required is returned as-is. The strict response schema then rejects it during HttpApi encoding, the SchemaErrorMiddleware at packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts:39 converts that to a 400 BadRequest with kind: "Body", and the renderer's wrapClientError rethrows it.

The drift counts line up exactly with when each field was tightened (commit dates from git log -S on the field name):

Field Required since Commit
StepFinishPart.reason 2025-10-24 736a85d42
Assistant.parentID 2025-10-22 28d8af48a
User.agent / User.model 2025-11-17 a1214fff2 (#4412)
CompactionPart.auto 2025-11-25 020ee56f2
Assistant.agent 2025-12-14 262d836dd (#5462)
ToolStateCompleted.{title, metadata, time} 2025-07-07 f88476644 (#743)

None of those commits shipped a packages/opencode/migration/ file or a packages/opencode/src/data-migration.ts entry to backfill the field on existing rows. The cutover from custom JSON storage to Drizzle in a48a5a346 ("core: migrate from custom JSON storage to standard Drizzle migrations") copied each on-disk JSON blob into message.data / part.data byte-for-byte without normalizing fields, so anything missing from the JSON stayed missing.

The historical remedy for legacy drift in this codebase has been to loosen the schema (e.g. 29250a0ef for token counts, c6e6bdf59 for negative cache reads). That approach doesn't work for these fields because downstream consumers like packages/opencode/src/session/projectors-next.ts:126,136 read data.agent / data.model without null-checks, and the HTTP API rejects encoding any response that violates the schema — loosening it would silently weaken the public API contract.

The drift counts also scale with how recently each field was introduced (e.g. 54,051 Assistant.agent missing because anything before Dec 14 2025 has it missing; only 14 compaction.auto missing because compaction parts are rarer overall). This is consistent with the "field was tightened, no migration shipped" hypothesis.

Workaround applied manually

We backfilled the offending opencode.db directly with SQLite json_set updates. The user's database is now fully repaired and the desktop app loads sessions again. Approach:

  1. Backed up ~/.local/share/opencode/opencode.db to a separate location first.
  2. Ran a diagnostic SQL scan over message and part to enumerate every row missing each required field, grouped by role / part type / tool.state.status so we knew the full blast radius before writing.
  3. Applied targeted UPDATE ... SET data = json_set(...) WHERE json_extract(data, '$.X') IS NULL statements, one per field, each in its own BEGIN IMMEDIATE transaction. Defaults used:
Field Default
agent (User, Assistant) "build"
User.model donor {providerID, modelID} from same session's assistant rows; literal {"anthropic", "claude-sonnet-4-5-20250929"} for the ~20 sessions with no donor
Assistant.mode "build"
Assistant.parentID id of the prior message in the same session, ordered by (time_created, id)
step-finish.reason "stop"
tool.state.title (completed) copied from data.tool (so users see "bash", "edit", etc.)
tool.state.metadata (completed) {}
tool.state.time.start / time.end (completed, 4 rows) message's time_created
tool.state.input / raw (1 stuck pending row) {} / ""
compaction.auto false

Total: ~143,000 row updates across both tables. After each transaction we re-ran the diagnostic scan and confirmed the missing-field count for that field dropped to zero. Final all-clear scan over every required field across both tables: zero rows missing anything.

Notes:

  • We did not stop the running opencode processes. SQLite WAL + BEGIN IMMEDIATE per chunk meant each update queued harmlessly against any concurrent writer.
  • All updates used json_set (touches only the targeted key) so the rest of each row's data is byte-identical to before.

Suggested upstream fix

Two complementary changes:

  1. Add a backfill data migration in packages/opencode/src/data-migration.ts. The infrastructure already exists (migrations array, DataMigrationTable for idempotency, Effect.forkScoped background execution). One or two new entries — e.g. message_default_fields and part_default_fields — would protect every other user whose DB predates the schema-tightening PRs. The SQL is straightforward UPDATE ... SET data = json_set(...) WHERE json_extract(data, '$.X') IS NULL, batched per-session like the existing session_usage_from_messages migration.

  2. (Optional, belt-and-suspenders) Add defensive defaulting in info(row) / part(row) at packages/opencode/src/session/message-v2.ts:580. This costs almost nothing at read time and would prevent the next round of "missing key" surprises if another field gets tightened later.

Environment

  • opencode desktop on macOS
  • Database file: ~/.local/share/opencode/opencode.db (~4 GB, sessions back to mid-2025)
  • Affected sessions: 2,155 with broken assistant rows; 1,659 with broken user rows; many thousands of part rows across sessions

Happy to send a PR for the data migration if it'd be useful — let me know.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions