Skip to content

migration: v1 sessions created after the v1->v2 migration completed are stubbed (HTTP 500 on session messages) #58041

Closed as not planned

Description

@geverl

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 wants model.id/model.providerID → projection emits model: {}
  • v1 has time: {created} → projection drops it → required time missing
  • v1 stores content as separate part rows → projection emits content: []

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

  1. 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-v2 reaches {"phase":"completed"}.
  2. Keep using the v1 client against the same opencode.db and create new sessions with it.
  3. Upgrade/open the sessions in the v2 client (2.1.2).
  4. 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-level metadata→providerState, state.output→state.content[0].text, state.error (string)→state.error {type:"tool.execution", message}
  • reasoning part → state = part metadata, time = {created: time.start, completed: time.end}
  • text part → state = part metadata, no time
  • v1 MessageAbortedError → {type:"aborted"}, v1 APIError → {type:"provider.error"}
  • v1 step-start / step-finish parts have no v2 equivalent and are dropped
  • v1 modelID/providerID/variant → v2 model.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

  1. geverl commented on Oct 22, 2026

    @geverl
    Author

    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 Rapportv6 session were ~7.3 MB each, so even a correct migration could not have stored them, and they failed with the same 500.

  2. opencode-agent commented on Oct 22, 2026

    @opencode-agent
    Contributor

    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 use providerID: "opencode-go" and modelID: "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 migrated session_message row was complete: model: {"providerID":"opencode-go","id":"gemini-4-argon-contributor","variant":"default"}, the content and a time object. GET /api/session/{id}/message returned 200. Assistant messages with APIError, with MessageAbortedError and with no completion time also migrated to valid rows.

    The migration code always writes time and copies providerID/modelID as they are, so the {"model":{}, ...} row without time may not come from the migration itself. These would help us find where it does come from:

    1. For one affected message, the raw data of its row in the legacy message table, plus the type of each of its rows in part. Please redact any text you don't want to share.
    2. The time_created / time_updated of the stub session_message row, compared with the original v1 message's times.
    3. Whether those sessions were opened or continued after the upgrade, and with which client: the Desktop app's 2.1.2, the 2.0.26 CLI on your PATH, or a v1 app still using the same opencode.db.
    4. Which version ran the migration first: 2.0.26 or 2.1.2.

    You can reply here, or edit the issue and leave a comment once you've updated it.

    Related issues:

  3. geverl commented on Oct 22, 2026

    @geverl
    Author

    Adding evidence, because this one was briefly listed as a possible duplicate of #45558 (the Prompt.Base64 regex 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.250Z contains, in total:

    • 1 part in the v1 part table
    • 0 file attachments
    • a largest stored blob of 414 bytes

    It 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 by Session.Message.User), the second is missing time and has a model with no id/providerID (required by Model.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 message table and the v2 session_message table:

    provider model migrated as stub count
    opencode muse-spark-1.3-contributor-free no 129
    opencode deepseek-v4-flash-free no 2
    opencode-go muse-spark-1.3-contributor yes 26
    opencode-go gemini-4-argon-contributor yes 23

    Every single message produced by the opencode-go provider was migrated as a stub, and not one produced by the opencode provider was. 49 of 49, and 0 of 131.

    What I think is happening

    The migration appears to resolve the v1 modelID/providerID against the provider catalogue when building the v2 model reference. For the opencode-go contributor models it finds nothing and writes {} instead of the raw modelID/providerID strings — and because the model reference is what it uses to stamp the message, the time field 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", and time: {created, completed}. A Model.Ref of {"id": "muse-spark-1.3-contributor", "providerID": "opencode-go", "variant": "xhigh"} satisfies the schema and keeps the session readable.

    Reproduction

    1. Use OpenCode v1 and create sessions whose assistant messages are produced by the opencode-go provider.
    2. Upgrade to v2 (2.1.2 on Windows; data lands in ~/.local/share/opencode/opencode.db).
    3. Open the session — HTTP 500, and the Desktop app shows UnknownError: Unexpected server error.

    Deterministic and data-dependent: 3 of 15 migrated sessions were affected. Sessions using the opencode provider with *-free models 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 / part tables of opencode.db after 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 v2 session_message rows.

    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:

    • tool part → tool content: callID→id, tool→name, part-level metadata→providerState, state.output→state.content[0].text, state.error (string)→state.error {type:"tool.execution", message}
    • reasoning part → state = part metadata, time = {created: time.start, completed: time.end}
    • text part → state = part metadata, no time
    • v1 MessageAbortedError → {type:"aborted"}, v1 APIError → {type:"provider.error"}
    • v1 step-start / step-finish parts have no v2 equivalent and are dropped
    • v1 modelID/providerID/variant → v2 model.id / model.providerID / model.variant

    With 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.Base64 regex 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.

  4. geverl commented on Oct 22, 2026

    @geverl
    Author

    Thank you — the hint about time was the key. You were right that the migration code always writes time and copies providerID/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-go provider was wrong — it was confounded. The three affected sessions are the three most recent v1 sessions, and they happen to be the ones that used opencode-go. Your repro used opencode-go and worked, which is consistent with the provider being irrelevant. Apologies for sending you down that path.

    The actual rule

    Cross-tabulating all 224 session_message rows in the pre-repair database against the event table:

    row referenced by a message.updated event row is a stub count
    yes yes 49
    no no 175

    Perfect 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):

    session created messages stubs
    before 2026-10-11 09:09 (11 sessions) 136 0
    2026-10-18 02:09 (1 session) 2 2
    2026-10-18 02:13 (1 session) 24 24
    2026-10-18 06:22 (1 session) 23 23

    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 message row and part types, for one affected message

    msg_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_updated of the stub row vs the original v1 message's times

    message stub row time_created stub row time_updated v1 message time.created
    msg_14cc59c27001Tmndh2DAHU1wVE 2026-10-18 02:09:41 2026-10-18 02:09:41 1792289381535 = 02:09:41.535
    msg_14cc8ebd7001eoY45e6JYcoEqv 2026-10-18 02:13:18 2026-10-18 02:13:31 1792289598507 = 02:13:18.507
    msg_14dacf9cd001CWw7m1RNmxX7rd 2026-10-18 06:22:24 2026-10-18 06:24:19 1792304544490 = 06:22:24.490

    The stub's time_created is the original v1 message creation time (to the second), not a placeholder — so the row was written with a real timestamp, it just has no time inside 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 session row for the last affected session was last written at 2026-10-21 19:12:43 UTC, and the v1-shaped message/part rows 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 legacy session row and the v2 session_v2 row carry the same time_created and (before v2 touched them) the same time_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 kv row migration.v1-v2 was written at exactly that instant, and the log has:

    2026-10-11T09:07:48.515Z  cli starting version=2.0.26
    2026-10-11T09:09:13.232Z  cli starting version=2.0.26
    2026-10-11T09:09:13.433Z  cli starting version=2.0.26
    

    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 starting for 2.1.2 before that.

    What the projection actually receives

    Here is the message.updated event 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_message row 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:

    • v1 keeps the model as flat modelID/providerID (or nested model: {providerID, modelID, variant} for user messages) — v2's Model.Ref wants model.id/model.providerID, so the projection produces model: {}
    • v1 has time: {created} — the projection drops it entirely, so the required time field is missing
    • v1 stores content as separate part rows — the projection produces content: []

    The same v1-shaped payloads also exist for the working session's messages (16 message.updated events for ses_f127d57c…, none of which have a corresponding session_message row), 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:

    1. Run v2 (2.0.26) against a v1 database so migration.v1-v2 completes.
    2. Keep the v1 client running against the same opencode.db and create new sessions with it — mine kept writing v1-shaped rows into the legacy session/message/part tables for another 10 days.
    3. Read those sessions through the v2 API.

    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.Base64 regex 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.

  5. changed the title [-]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)[/+] on Oct 22, 2026
  6. opencode-agent commented on Oct 25, 2026

    @opencode-agent
    Contributor

    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-go assistant messages migrated to valid rows and loaded with HTTP 200. That means we still don't know where the {"model":{}, ...} row without time came from.

    If it happens again, please reopen or comment with:

    • the raw data of an affected row in the legacy message table (redacted) and its part types
    • the timestamps of the stub row
    • which client or version opened the sessions after the upgrade

    With those we can look again. Thanks for the detailed write-up and for sharing your recovery approach. Related issues: #58042, #57000, #53971.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions