Repository navigation
Description
Description
After upgrading from OpenCode v1 to v2, some sessions are permanently unreadable. GET /api/session/{id}/message returns HTTP 500, and the Desktop app surfaces it as:
UnknownError: Unexpected server error. Check server logs for details.
The affected sessions have every message stored as a stub in the v2 session_message table:
{"agent":"build","model":{},"content":[],"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}}}The v2 Session.Message.Assistant schema requires id, time, type, agent, model and content, and Model.Ref requires id + providerID (both from GET /openapi.json on the running server). The stub violates both, so the decode throws and the whole session returns 500 — including the many user messages in it that are perfectly fine.
Root cause (established in the comments below): these are not written by the bulk v1→v2 migration. They are written by the event projection, and the trigger is that the sessions were created after the v1→v2 migration had already completed — i.e. a v1 client kept writing v1-shaped rows into the same opencode.db after the migration ran. The projection then materialises session_message rows from message.updated events whose payload is the raw v1 message JSON, which it does not translate:
- v1 keeps the model as flat
modelID/providerID→ v2 wantsmodel.id/model.providerID→ projection emitsmodel: {} - v1 has
time: {created}→ projection drops it → requiredtimemissing - v1 stores content as separate
partrows → projection emitscontent: []
In my case the trigger is perfectly clean: the 11 sessions created before the migration completed have 0 stubbed messages out of 136, and the 3 sessions created after it have 100% stubbed messages (2/2, 24/24, 23/23).
For comparison, a correctly migrated assistant row looks like this:
{"agent":"build","model":{"providerID":"opencode","id":"muse-spark-1.3-contributor-free","variant":"xhigh"},"content":[{"type":"reasoning","text":"…"},{"type":"text","text":"…"}],"finish":"stop","cost":0,"tokens":{"input":8240,"output":177,"reasoning":345,"cache":{"read":241,"write":0}},"time":{"created":…,"completed":…}}Server log (~/.local/share/opencode/log/opencode.log):
level=ERROR message="failed to decode session message" sessionID=ses_… messageID=msg_…
level=INFO message="Sent HTTP response" http.method=GET http.url=/api/session/ses_…/message http.status=500
OpenCode version
2.1.2 (the CLI bundled with the Desktop app, ai.opencode.desktop/cli/2.1.2-x64). The standalone CLI on PATH was 2.0.26, and 2.0.26 is the version that actually ran the v1→v2 migration (see the comments). Install/channel: latest.
Operating System
Windows 11 Education, 10.0.26300 (win32 x64)
Terminal
TERM=xterm-256color, no TERM_PROGRAM/COLORTERM (not Windows Terminal). Shell: C:\WINDOWS\system32\cmd.exe.
Plugins
None — ~/.config/opencode/opencode.jsonc contains only $schema.
Steps to reproduce
- Use OpenCode v1 (1.18.32) against a database, and let a v2 client (2.0.26) run the v1→v2 migration so that
kv.migration.v1-v2reaches{"phase":"completed"}. - Keep using the v1 client against the same
opencode.dband create new sessions with it. - Upgrade/open the sessions in the v2 client (2.1.2).
- Observe the error.
The bug is deterministic and data-dependent: 3 of 14 sessions were affected, and every message in them.
Expected behavior
Messages written by a v1 client after the migration completed should still be readable in v2. Either the ingest path should translate the v1 message shape into the v2 shape (mapping modelID→model.id, providerID→model.providerID, keeping time, and building content from the parts), or the projection should fall back to a schema-valid row rather than one that fails to decode.
Actual behavior
Every message in the affected sessions is stored as a schema-invalid stub, so the entire session returns HTTP 500 and is effectively lost. A single undecodable message takes the whole session with it.
Additional context
The original v1 data is still present after the migration, in the legacy message / part tables of opencode.db (186 message rows, 623 part rows, 61 MB of attachment payloads), so the information is recoverable — but the API only reads the v2 session_message rows.
I was able to recover the affected sessions locally by re-deriving the v2 rows from the legacy v1 message/part tables. To confirm the re-derivation was faithful, I re-derived the rows of 12 correctly migrated sessions and diffed them against opencode's own output: 61/61 rows matched exactly (the only difference was a 1–4 ms drift in time.completed).
The v1 → v2 field mappings I derived, in case they are useful for a fix:
- tool part → tool content:
callID→id,tool→name, part-levelmetadata→providerState,state.output→state.content[0].text,state.error(string)→state.error{type:"tool.execution", message} - reasoning part →
state= partmetadata,time={created: time.start, completed: time.end} - text part →
state= partmetadata, notime - v1
MessageAbortedError→{type:"aborted"}, v1APIError→{type:"provider.error"} - v1
step-start/step-finishparts have no v2 equivalent and are dropped - v1
modelID/providerID/variant→ v2model.id/model.providerID/model.variant
With the rebuild applied, all 15 sessions now load with their reasoning, text and tool-call content intact.
This may belong with #57000 / #57080 ("v1 messages written after the migration completed") or #53971 ("v1 messages silently dropped during migration") rather than here.
Activity
geverl commentedon Oct 22, 2026
Related issue: #58042 - inline attachments over ~5.3 MB cannot be decoded (HTTP 500 on session messages).
That is a separate defect, but it bit the same session: three PDFs in the
Rapportv6session were ~7.3 MB each, so even a correct migration could not have stored them, and they failed with the same 500.opencode-agent commentedon Oct 22, 2026
Thanks for the detailed report and the field mappings. I couldn't reproduce this yet.
What I tried: I created a real v1 database with
[email protected]and added a session whose assistant messages useproviderID: "opencode-go"andmodelID: "gemini-4-argon-contributor", with step-start, text and step-finish parts. I then started@opencode/[email protected]on it so it ran the migration. The migratedsession_messagerow was complete:model: {"providerID":"opencode-go","id":"gemini-4-argon-contributor","variant":"default"}, the content and atimeobject.GET /api/session/{id}/messagereturned 200. Assistant messages withAPIError, withMessageAbortedErrorand with no completion time also migrated to valid rows.The migration code always writes
timeand copiesproviderID/modelIDas they are, so the{"model":{}, ...}row withouttimemay not come from the migration itself. These would help us find where it does come from:dataof its row in the legacymessagetable, plus thetypeof each of its rows inpart. Please redact any text you don't want to share.time_created/time_updatedof the stubsession_messagerow, compared with the original v1 message's times.opencode.db.You can reply here, or edit the issue and leave a comment once you've updated it.
Related issues:
geverl commentedon Oct 22, 2026
Adding evidence, because this one was briefly listed as a possible duplicate of #45558 (the
Prompt.Base64regex failing on multi-MB strings). I don't think it is the same bug — there is no base64 involved here, and the failure is not length-dependent.The strongest counter-example
The session
New session - 2026-10-18T02:09:41.250Zcontains, in total:parttableIt still returned HTTP 500 with
failed to decode session message. The two messages it contains were migrated as:{"text":"I have an Ubuntu 24.04 server, which is a Proxmox LXC container. …","time":{"created":1792289381250}}{"agent":"build","model":{},"content":[],"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}}}The first is missing
time(required bySession.Message.User), the second is missingtimeand has amodelwith noid/providerID(required byModel.Ref). Both violate the schema for reasons that have nothing to do with string length.The same holds for two of the three messages originally reported as failing — their v1 blobs were 464 and 1262 bytes, and one of them has zero parts.
The correlation is total
Of the 180 messages that exist in both the v1
messagetable and the v2session_messagetable:Every single message produced by the
opencode-goprovider was migrated as a stub, and not one produced by theopencodeprovider was. 49 of 49, and 0 of 131.What I think is happening
The migration appears to resolve the v1
modelID/providerIDagainst the provider catalogue when building the v2 model reference. For theopencode-gocontributor models it finds nothing and writes{}instead of the rawmodelID/providerIDstrings — and because the model reference is what it uses to stamp the message, thetimefield is dropped at the same time.What would be needed to fix it
Even when a model can't be resolved, the message should still be written in a schema-valid form. The v1 data has everything needed:
modelID: "muse-spark-1.3-contributor",providerID: "opencode-go",variant: "xhigh", andtime: {created, completed}. AModel.Refof{"id": "muse-spark-1.3-contributor", "providerID": "opencode-go", "variant": "xhigh"}satisfies the schema and keeps the session readable.Reproduction
opencode-goprovider.~/.local/share/opencode/opencode.db).UnknownError: Unexpected server error.Deterministic and data-dependent: 3 of 15 migrated sessions were affected. Sessions using the
opencodeprovider with*-freemodels migrated correctly, which is why only some sessions broke.Recovery, in case it helps others
The original v1 data is still in the legacy
message/parttables ofopencode.dbafter the migration (186 message rows, 623 part rows, 61 MB of attachment payloads), so the content is recoverable even though the API only reads the v2session_messagerows.I recovered the three affected sessions by re-deriving the v2 rows from the legacy v1 tables. To be sure the re-derivation was faithful I re-derived the rows of 12 correctly migrated sessions and diffed them against OpenCode's own output: 61/61 rows matched exactly (the only difference was a 1–4 ms drift in
time.completed).The mappings I derived, which may be useful for a migration fix:
callID→id,tool→name, part-levelmetadata→providerState,state.output→state.content[0].text,state.error(string)→state.error{type:"tool.execution", message}state= partmetadata,time={created: time.start, completed: time.end}state= partmetadata, notimeMessageAbortedError→{type:"aborted"}, v1APIError→{type:"provider.error"}step-start/step-finishparts have no v2 equivalent and are droppedmodelID/providerID/variant→ v2model.id/model.providerID/model.variantWith the rebuild applied, all 15 sessions load with their reasoning, text and tool-call content intact.
One caveat, so this doesn't mislead anyone: one of these three sessions also contained three multi-MB PDF attachments, and those fail for a completely different reason — the
Prompt.Base64regex misbehaving on long strings, tracked in #45558. So the two bugs co-occur here. But the session I used as the counter-example above has no attachments at all, and the message-level correlation is 49/49 versus 0/131, which the regex explanation cannot account for.geverl commentedon Oct 22, 2026
Thank you — the hint about
timewas the key. You were right that the migration code always writestimeand copiesproviderID/modelID: the stub rows are not written by the bulk migration at all. They're written by the event projection, and the trigger is that those sessions were created after the v1→v2 migration had already completed.First, a retraction
My earlier claim that this is specific to the
opencode-goprovider was wrong — it was confounded. The three affected sessions are the three most recent v1 sessions, and they happen to be the ones that usedopencode-go. Your repro usedopencode-goand worked, which is consistent with the provider being irrelevant. Apologies for sending you down that path.The actual rule
Cross-tabulating all 224
session_messagerows in the pre-repair database against theeventtable:message.updatedeventPerfect 1:1 — 49/49 event-referenced rows are stubs, 175/175 non-referenced rows are complete and schema-valid. The discriminator is not the provider, it's the write path.
And grouping the same rows by when the session was created, relative to the v1→v2 migration completing (
kv.migration.v1-v2 = {"phase":"completed"}, written 2026-10-11 09:09:13 UTC):So: sessions created before the migration completed are fine; every session created after it is 100% stubbed. That's the whole population — 11 good sessions, 3 broken ones, no exceptions.
Your four questions
1. Raw legacy
messagerow and part types, for one affected messagemsg_14cc59cb2001I1m3XvvWJ74utG(assistant, 0 part rows, 1262-byte v1 blob):{"parentID":"msg_14cc59c27001Tmndh2DAHU1wVE","role":"assistant","mode":"build","agent":"build","variant":"xhigh","path":{"cwd":"<project dir>","root":"/"},"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}},"modelID":"muse-spark-1.3-contributor","providerID":"opencode-go","time":{"created":1792289381554},"error":{"name":"MessageAbortedError","data":{"message":"Aborted"}}}Part rows for that message: none (0 rows).
2.
time_created/time_updatedof the stub row vs the original v1 message's timestime_createdtime_updatedtime.createdmsg_14cc59c27001Tmndh2DAHU1wVEmsg_14cc8ebd7001eoY45e6JYcoEqvmsg_14dacf9cd001CWw7m1RNmxX7rdThe stub's
time_createdis the original v1 message creation time (to the second), not a placeholder — so the row was written with a real timestamp, it just has notimeinside the JSON payload.3. Were the sessions opened or continued after the upgrade, and with which client?
Continued, in v1, after the migration had already completed. The legacy v1-shaped
sessionrow for the last affected session was last written at 2026-10-21 19:12:43 UTC, and the v1-shapedmessage/partrows for all three affected sessions are dated 2026-10-18. The v2 desktop app (2.1.2) was only installed at 2026-10-22 05:04 UTC, and its first request for these sessions already returned 500. The legacysessionrow and the v2session_v2row carry the sametime_createdand (before v2 touched them) the sametime_updated, so both were being written by the same v1-shaped writer.4. Which version ran the migration first — 2.0.26 or 2.1.2?
2.0.26, on 2026-10-11 09:09:13 UTC. The
kvrowmigration.v1-v2was written at exactly that instant, and the log has:The desktop app 2.1.2 and its bundled CLI weren't installed until 2026-10-22 05:04 UTC, and the log has no
cli startingfor 2.1.2 before that.What the projection actually receives
Here is the
message.updatedevent that produced the stub above (evt_14cc59cb2001qlFv2Hzx3t8N5m,event.data.info):{ "id": "msg_14cc59cb2001I1m3XvvWJ74utG", "sessionID": "ses_eb33a647dffeA6Rnd81Da8zgzz", "role": "assistant", "time": { "created": 1792289381554 }, "parentID": "msg_14cc59c27001Tmndh2DAHU1wVE", "modelID": "muse-spark-1.3-contributor", "providerID": "opencode-go", "mode": "build", "agent": "build", "path": { "cwd": "<project dir>", "root": "/" }, "cost": 0, "tokens": { "input": 0, "output": 0, "reasoning": 0, "cache": { "read": 0, "write": 0 } } }and the
session_messagerow it produced:{"agent":"build","model":{},"content":[],"cost":0,"tokens":{"input":0,"output":0,"reasoning":0,"cache":{"read":0,"write":0}}}The payload is the raw v1 message JSON, and the projection is clearly not translating it:
modelID/providerID(or nestedmodel: {providerID, modelID, variant}for user messages) — v2'sModel.Refwantsmodel.id/model.providerID, so the projection producesmodel: {}time: {created}— the projection drops it entirely, so the requiredtimefield is missingpartrows — the projection producescontent: []The same v1-shaped payloads also exist for the working session's messages (16
message.updatedevents forses_f127d57c…, none of which have a correspondingsession_messagerow), so the payload shape alone isn't the trigger — what matters is that the affected messages went through the event path in the first place.Reproduction recipe
I think this is why your repro didn't hit it: you created a fresh v1 database and ran the migration once. My case is:
migration.v1-v2completes.opencode.dband create new sessions with it — mine kept writing v1-shaped rows into the legacysession/message/parttables for another 10 days.Those post-migration v1 messages get ingested through the event path, and the projection writes the stub.
This looks like the same family as the issues you already linked — #57000 / #57080 ("v1 messages written after the migration completed") and #53971 ("v1 messages silently dropped during migration"). Those may be the better home for the fix; I'll leave it to you to decide whether to keep it here.
Aside: the attachment limit is a separate problem
Two of the three affected sessions also contain multi-MB PDF attachments, and those fail for a completely unrelated reason — the
Prompt.Base64regex misbehaving on long strings (tracked in #45558, my #58042 is a duplicate of it). The one session in this report with no attachment and a largest stored blob of 414 bytes still fails, so the two issues are independent, but both land on the same three sessions.[-]migration: v1 sessions with opencode-go provider fail to decode after upgrade (HTTP 500)[/-][+]migration: v1 sessions created after the v1->v2 migration completed are stubbed (HTTP 500 on session messages)[/+]opencode-agent commentedon Oct 25, 2026
We're closing this for now because we couldn't reproduce it and haven't heard back. On 2.1.2, a v1 session with
opencode-goassistant messages migrated to valid rows and loaded with HTTP 200. That means we still don't know where the{"model":{}, ...}row withouttimecame from.If it happens again, please reopen or comment with:
dataof an affected row in the legacymessagetable (redacted) and its part typesWith those we can look again. Thanks for the detailed write-up and for sharing your recovery approach. Related issues: #58042, #57000, #53971.