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:
- Backed up
~/.local/share/opencode/opencode.db to a separate location first.
- 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.
- 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:
-
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.
-
(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.
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 whoseopencode.dbpredates one of several schema-tightening PRs from Q3–Q4 2025. Once one bad row exists in a session, the wholeGET /session/:sessionID/messageresponse 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.dbcontaining 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:Assistantrows missingagentAssistantrows missingparentIDAssistantrows missingmodeUserrows missingagentUserrows missingmodel(the whole{providerID, modelID}object)step-finishparts missingreasoncompletedtool parts missingstate.metadata/state.titlecompactionparts missingautocompletedtool parts missingstate.time.start/state.time.endpendingtool part missingstate.input/state.rawLoading 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 isSchema.Array(MessageV2.WithParts)), surfaced to the desktop renderer through the SDK'swrapClientErrorinpackages/sdk/js/src/error-interceptor.ts.Example renderer stack trace:
{ "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:
Root cause analysis
The
User/Assistantschemas inpackages/opencode/src/session/message-v2.tsand the variousPartvariants declare these fields as required (Schema.String,Schema.Finite, etc.), but the read path atmessage-v2.ts:580-593(info(row)/part(row)) splatsrow.dataand casts toInfo/Partwith no validation or defaulting: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
SchemaErrorMiddlewareatpackages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts:39converts that to a400 BadRequestwithkind: "Body", and the renderer'swrapClientErrorrethrows it.The drift counts line up exactly with when each field was tightened (commit dates from
git log -Son the field name):StepFinishPart.reason736a85d42Assistant.parentID28d8af48aUser.agent/User.modela1214fff2(#4412)CompactionPart.auto020ee56f2Assistant.agent262d836dd(#5462)ToolStateCompleted.{title, metadata, time}f88476644(#743)None of those commits shipped a
packages/opencode/migration/file or apackages/opencode/src/data-migration.tsentry to backfill the field on existing rows. The cutover from custom JSON storage to Drizzle ina48a5a346("core: migrate from custom JSON storage to standard Drizzle migrations") copied each on-disk JSON blob intomessage.data/part.databyte-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.
29250a0effor token counts,c6e6bdf59for negative cache reads). That approach doesn't work for these fields because downstream consumers likepackages/opencode/src/session/projectors-next.ts:126,136readdata.agent/data.modelwithout 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.agentmissing because anything before Dec 14 2025 has it missing; only 14compaction.automissing 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.dbdirectly with SQLitejson_setupdates. The user's database is now fully repaired and the desktop app loads sessions again. Approach:~/.local/share/opencode/opencode.dbto a separate location first.messageandpartto enumerate every row missing each required field, grouped byrole/ parttype/tool.state.statusso we knew the full blast radius before writing.UPDATE ... SET data = json_set(...) WHERE json_extract(data, '$.X') IS NULLstatements, one per field, each in its ownBEGIN IMMEDIATEtransaction. Defaults used:agent(User, Assistant)"build"User.model{providerID, modelID}from same session's assistant rows; literal{"anthropic", "claude-sonnet-4-5-20250929"}for the ~20 sessions with no donorAssistant.mode"build"Assistant.parentID(time_created, id)step-finish.reason"stop"tool.state.title(completed)data.tool(so users see"bash","edit", etc.)tool.state.metadata(completed){}tool.state.time.start/time.end(completed, 4 rows)time_createdtool.state.input/raw(1 stuck pending row){}/""compaction.autofalseTotal: ~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:
BEGIN IMMEDIATEper chunk meant each update queued harmlessly against any concurrent writer.json_set(touches only the targeted key) so the rest of each row'sdatais byte-identical to before.Suggested upstream fix
Two complementary changes:
Add a backfill data migration in
packages/opencode/src/data-migration.ts. The infrastructure already exists (migrationsarray,DataMigrationTablefor idempotency,Effect.forkScopedbackground execution). One or two new entries — e.g.message_default_fieldsandpart_default_fields— would protect every other user whose DB predates the schema-tightening PRs. The SQL is straightforwardUPDATE ... SET data = json_set(...) WHERE json_extract(data, '$.X') IS NULL, batched per-session like the existingsession_usage_from_messagesmigration.(Optional, belt-and-suspenders) Add defensive defaulting in
info(row)/part(row)atpackages/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
~/.local/share/opencode/opencode.db(~4 GB, sessions back to mid-2025)Happy to send a PR for the data migration if it'd be useful — let me know.