Skip to content

migration: sessions created via 1.19 /api/session lose all messages on v1->v2 migration (2.1.3) #58543

Open

Description

@justinhornberger-ctrl

Description

Upgrading a 1.19.x database to 2.1.3 keeps every session created through 1.19's /api/session endpoint (the "next" session store, rows in session_message) but drops all of its messages. The session row survives; GET /api/session/{id}/message then returns 200 with an empty page — no error, no log line. Sessions created through the legacy routes (rows in message/part) migrate correctly.

Related but different from #58041 (that one covers v1 sessions created after the migration, which become malformed stubs and 500). Here the sessions existed before the migration, were already in the 1.19 session_message format, and come back empty.

Measured (copy of a real 1.19.3 database, 832 sessions)

before (1.19.3) after starting 2.1.3 on the copy
session_message rows / distinct sessions 12,717 / 308 12,184 / 503
legacy message rows / sessions 11,737 / 502 11,741 / 503

Per session, via GET /api/session/{id}/message on 2.1.3:

  • legacy-store sessions: 22 → 23, 18 → 19, 28 → 29, 2 → 2 messages (all present)
  • 1.19 session_message sessions: 32 → 0, 167 → 0, 111 → 0, 2 → 0

GET /api/experimental/migration/v1 reports completed; the server log shows only the schema migrations and no errors.

So the bulk migration appears to rebuild session_message from the legacy tables and discard the rows 1.19 had already written there for its own /api/session sessions.

Steps to reproduce

  1. With [email protected], opencode serve, then create a session with POST /api/session and send a couple of prompts with POST /api/session/{id}/prompt. Confirm GET /api/session/{id}/message returns them.
  2. Stop the server. Copy the data dir.
  3. Start @opencode/[email protected] (opencode serve) on that data dir and wait for /api/experimental/migration/v1 → completed.
  4. GET /api/session/{id}/message for the session from step 1 → empty.

Expected

Sessions already stored in the 1.19 session_message format keep their messages through the migration (they need no translation).

Environment

  • From: opencode-ai 1.19.3 (linux-x64), server mode
  • To: @opencode/cli 2.1.3 (linux-x64, npm), opencode serve
  • Provider: @ai-sdk/openai-compatible custom provider

Activity

  1. opencode-agent commented on Oct 23, 2026

    @opencode-agent
    Contributor

    Thanks for the very precise report. I reproduced this and it still happens on the latest dev (50989519c).

    Steps, with a mock OpenAI-compatible provider:

    1. On [email protected], create a session with POST /api/session and send two prompts via POST /api/session/{id}/prompt. The messages are returned and stored in session_message, and the legacy message table stays empty.
    2. Copy the data dir and start @opencode/[email protected] on it.
    3. /api/experimental/migration/v1 reports completed and the session itself is still there, but GET /api/session/{id}/message returns {"data":[]}. All of that session's session_message rows have been deleted, with nothing in the log.

    The cause seems to be in the V1 migration's per-session copy. It deletes the session's existing session_message rows and rebuilds them only from the legacy message/part tables, which are empty for sessions created through /api/session. It is not the reset_v2_session_state schema migration: 1.19.3 had already applied that one.

    The script below reproduces this end to end against 2.1.3.

    Related: #58041 (v1 sessions created after the migration become stubs and return 500), and #50130 / #57080 (v1 sessions written after the migration are not imported).

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Repro for #58543: 1.19 /api/session sessions lose their session_message rows in the v1->v2 migration.
    # Needs bun, curl, python3. Uses a mock OpenAI-compatible provider on :4999.
    # Step 2 uses `opencode` from PATH (bun add -g @opencode/[email protected]). Set OC_DEV=/path/to/opencode-checkout to run step 2 from source instead of @opencode/[email protected].
    set -euo pipefail
    W=${W:-/tmp/oc-58543}; rm -rf "$W"; mkdir -p "$W/proj"; cd "$W"
    cat > mock.ts <<'TS'
    Bun.serve({port:4999, async fetch(req){
      const body:any = await req.json().catch(()=>({}))
      if(!body.stream) return Response.json({id:"x",object:"chat.completion",created:0,model:"mock",choices:[{index:0,message:{role:"assistant",content:"Title"},finish_reason:"stop"}],usage:{prompt_tokens:1,completion_tokens:1,total_tokens:2}})
      const c=[{choices:[{index:0,delta:{role:"assistant",content:"Hello from mock"}}]},{choices:[{index:0,delta:{},finish_reason:"stop"}],usage:{prompt_tokens:5,completion_tokens:3,total_tokens:8}}]
      return new Response(c.map(x=>`data: ${JSON.stringify({id:"x",object:"chat.completion.chunk",created:0,model:"mock",...x})}\n\n`).join("")+"data: [DONE]\n\n",{headers:{"content-type":"text/event-stream"}})
    }})
    TS
    bun mock.ts & MOCK=$!
    cat > proj/opencode.json <<'J'
    {"model":"mock/mock","small_model":"mock/mock","provider":{"mock":{"npm":"@ai-sdk/openai-compatible","name":"Mock","options":{"baseURL":"http://127.0.0.1:4999/v1","apiKey":"x"},"models":{"mock":{"name":"mock"}}}}}
    J
    env_for(){ export XDG_DATA_HOME=$W/$1/data XDG_CONFIG_HOME=$W/$1/config XDG_STATE_HOME=$W/$1/state XDG_CACHE_HOME=$W/$1/cache; }
    count(){ python3 -c "import sqlite3,sys;print(sqlite3.connect(sys.argv[1]).execute('select count(*) from session_message').fetchone()[0])" "$1"; }
    
    # --- Step 1: [email protected] ---
    (cd proj; env_for v1; bunx --bun [email protected] serve --port 4100 > $W/v1.log 2>&1) & V1=$!
    until curl -sf localhost:4100/doc >/dev/null; do sleep 1; done
    B=http://127.0.0.1:4100
    SID=$(curl -s -XPOST $B/api/session -H 'content-type: application/json' -d '{"model":{"id":"mock","providerID":"mock"}}' | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["id"])')
    for t in "first prompt" "second prompt"; do
      curl -s -XPOST $B/api/session/$SID/prompt -H 'content-type: application/json' -d "{\"prompt\":{\"text\":\"$t\"}}" >/dev/null
      curl -s -XPOST $B/api/session/$SID/wait >/dev/null
    done
    sleep 2
    echo "1.19.3 messages for $SID:"; curl -s "$B/api/session/$SID/message?limit=50" | python3 -c 'import json,sys;print([m["type"] for m in json.load(sys.stdin)["data"]])'
    pkill -P $V1 || true; kill $V1 || true; sleep 2
    echo "session_message rows before: $(count $W/v1/data/opencode/opencode.db)"
    
    # --- Step 2: v2 on a copy of the data dir ---
    cp -r v1 v2; DB=$W/v2/data/opencode/opencode.db
    if [ -n "${OC_DEV:-}" ]; then
      (cd proj; env_for v2; OPENCODE_DB=$DB bun --conditions=browser --preload=$OC_DEV/packages/tui/node_modules/@opentui/solid/scripts/preload.js $OC_DEV/packages/cli/src/index.ts serve --port 4200 > $W/v2.log 2>&1) & V2=$!
    else
      (cd proj; env_for v2; opencode serve --port 4200 > $W/v2.log 2>&1) & V2=$!
    fi
    until grep -q 'password' $W/v2.log 2>/dev/null; do sleep 1; done
    A="opencode:$(grep -o 'password .*' $W/v2.log | cut -d' ' -f2)"
    until curl -s -u "$A" localhost:4200/api/experimental/migration/v1 | grep -q completed; do sleep 1; done
    echo "v2 session: $(curl -s -u "$A" localhost:4200/api/session/$SID | head -c 80)"
    echo "v2 messages: $(curl -s -u "$A" "localhost:4200/api/session/$SID/message?limit=50")"
    echo "session_message rows after: $(count $DB)"
    pkill -P $V2 || true; kill $V2 $MOCK || true

    Run it: bash repro.sh

  2. samvallad33 commented on Oct 24, 2026

    @samvallad33

    Hi justinhornberger-ctrl,

    Did any machine you actually work on already upgrade, or only the copy?

    A migration that empties 308 sessions should never report completed without a single log line.

    Want a look at a stripped down design pairing memory, security and authority so old sessions stay findable?

    Sam

  3. justinhornberger-ctrl commented on Oct 24, 2026

    @justinhornberger-ctrl
    Author

    Only a copy. The live machine is still on 1.19.3 and was never upgraded; I ran 2.1.3 against a backup of its database in an isolated sandbox, and deleted the copy afterwards. Agreed on the silent completed. Thanks for the offer, but I'll wait for the upstream fix rather than adopt a separate design.

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