Skip to content

session: /move reports success but silently no-ops when a V1 inline-PDF part fails to decode #55128

Open

Description

@snowlooy

Summary

POST /api/session/{sessionID}/move returns HTTP 200 (CLI exit code 0, empty body) but silently rolls back when the pre-move "drain Session" step throws Session.MessageDecodeError. The session's project_id is left unchanged, so the client believes the move succeeded while nothing was persisted. In my case the decode error is triggered by a V1-era message part that inlines a ~28 MB PDF as a base64 data: URL (~37.6 MB part), which V2's decoder cannot handle.

Two distinct defects are involved:

  1. move reports success on failure — a write endpoint must not return 200/exit-0 when it aborts. It should surface an error.
  2. V1 large inline-PDF file part fails to decode in V2 — a V1→V2 compatibility regression; the migration guide states V1 data should keep working.

Environment

  • opencode version: v2.0.27
  • OS: Windows 11 Home (zh-CN), 10.0.26200.0, x64
  • Terminal: Windows Terminal (TERM=xterm-256color; TERM_PROGRAM unset)
  • Shell: Windows PowerShell 5.1 (ComSpec=C:\WINDOWS\system32\cmd.exe)
  • Install/channel: release / latest channel, npm global install (opencode-ai)
  • Active plugins: none found in config

Reproduction

Context: recovering sessions orphaned by the known V1→V2 attribution bug (#50551), where non-git-directory sessions keep project_id='global' and never appear in the per-directory picker. POST /api/session/{id}/move is the documented workaround. It worked for 59 of 61 sessions; the 2 that contained a large inline-PDF attachment failed silently.

  1. In V1 (e.g. 1.14.x), in a non-git directory, create a session and attach a large PDF (~28 MB). V1 stores it as a message file part whose url is an inline data:application/pdf;base64,... string (~37.6 MB for the whole part).
  2. Upgrade to V2 (2.0.27) against the same opencode.db; let the V1→V2 migration run.
  3. Call the move workaround to re-home the session to its directory's real project:
    opencode api post /api/session/<sessionID>/move --data '{"directory":"C:\\Users\\<user>"}'
  4. Observe exit code 0 and an empty response body (apparent success).
  5. Query the session again: opencode api get /api/session/<sessionID> → projectID is still global; the DB row's project_id/path are unchanged.

Expected Behavior

  • move should either persist the new project_id/path or return a non-2xx error describing the failure. It must not report success when the operation was rolled back.
  • Independently, V2 should decode V1 messages that contain large inline-PDF file parts (or degrade gracefully, e.g. keep the text and drop/summarize the oversized attachment) instead of throwing MessageDecodeError, per the V1→V2 compatibility guarantee.

Actual Behavior

  • move returns HTTP 200 / exit code 0 / empty body, but nothing is written. Verified immediately afterward via GET /api/session/{id}: projectID still global.
  • Server log shows the swallowed failure at the moment of the move call:
    level=ERROR role=server message="Failed to drain Session"
      cause="Session.MessageDecodeError: Failed to decode message msg_e0a99b3de… in session ses_1f56df86…"
    level=ERROR role=server message="Failed to drain Session"
      cause="Session.MessageDecodeError: Failed to decode message msg_e0a5d6f2f… in session ses_1f5a290e…"
    
    (IDs truncated; they are opaque random identifiers.)
  • The offending part is structurally valid — keys type,mime,filename,url,source all present, base64 well-formed (decodes to a valid %PDF- file). The only thing unusual is its size:
    • url string length: 37,591,492 chars (data:application/pdf;base64,...)
    • decoded payload: 28,193,596 bytes (~28 MB), magic %PDF- OK
    • whole part JSON: 37,591,709 bytes (~37.6 MB)
  • Whole-DB scan: 21 file parts total; only 2 sessions contain an inline-PDF part; 7 file parts have a data: URL > 1 MB. The 2 sessions with the largest inline PDFs are exactly the 2 that fail to move.

Additional Context

  • Frequency: 100% reproducible for the 2 affected sessions; every move attempt returns success but leaves project_id='global'.
  • Impact: The move endpoint is the recommended workaround for V1→V2 importer keeps project_id='global' for non-git directories, so migrated sessions never appear in the per-directory session picker #50551. Its silent failure makes that workaround unreliable for any session containing an undecodable message, with no error surfaced to the user, TUI, scripts, or integrations. I had to work around it by directly updating session_v2.project_id/path in the DB (after a full backup) to replicate what a successful move does.
  • Redactions: PDF filename, the attachment's original on-disk path (in source.path), the local username, and full session/message/part IDs were redacted or truncated. No file contents, base64 payloads, tokens, or keys are included — only sizes and the %PDF- magic check.
  • Suspected cause of defect 2: a size/length cap in the V2 message/part decoder or schema validation that a ~37.6 MB part exceeds. The base64 itself is valid, so this is a size limit, not a format mismatch.
  • Related: V1→V2 importer keeps project_id='global' for non-git directories, so migrated sessions never appear in the per-directory session picker #50551 (non-git sessions stranded in global; root reason the move workaround was needed here). Defect 1 (move returns 200 while rolling back) appears to be previously unreported.

Activity

  1. opencode-agent commented on Oct 13, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I reproduced both problems on v2.0.27, and they still happen on the latest dev (c1615b1).

    What I did:

    • Created a session and added a user message with an inline PDF attachment.
    • Replaced the attachment in the database with ~8 MB of valid base64.
    • Called opencode api post /api/session/<id>/move --data '{"directory":"<dest>"}'.

    The command exited 0 with an empty body, but the session's project_id and directory didn't change. The move request stayed queued in session_inbox, and the server log showed Failed to drain Session with Session.MessageDecodeError (or the base64 regex SchemaError when the message hadn't been delivered to history yet). The script below reproduces it.

    From reading the code (I haven't tested a fix): move only queues the move for the session runner and returns straight away. If the runner fails while loading history, the move is silently dropped.

    The decode failure itself looks like a known problem: the Prompt.Base64 regex breaks on multi-MB strings. That's tracked in #45558, with an open fix in #51272. Related: #55110 (same decode failure from a large attachment) and #49740 (drain failures are never reported to the client).

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Repro for #55128: POST /api/session/:id/move returns success (exit 0) but the
    # move is never applied when the session's history fails to decode
    # (large inline file attachment -> Session.MessageDecodeError during drain).
    #
    # Usage: OC="opencode" ./repro.sh        (installed release)
    #        OC="bun --conditions=browser --preload=.../preload.js .../packages/cli/src/index.ts" ./repro.sh
    set -u
    OC=${OC:-opencode}
    export HOME=$(mktemp -d /tmp/opencode/oc-move-XXXX)
    mkdir -p "$HOME/dest"; cd "$HOME"
    # When running from a source checkout, the spawned service needs the opentui preload.
    [ -n "${PRELOAD:-}" ] && printf 'preload = ["%s"]\n' "$PRELOAD" > "$HOME/bunfig.toml"
    
    $OC service start >/dev/null
    S=$($OC api post /api/session --data "{\"directory\":\"$HOME\"}" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["id"])')
    echo "session: $S"
    # Admit a user message with a small inline PDF (resume:false -> no model call).
    $OC api post /api/session/$S/prompt --data '{"text":"hi","resume":false,"files":[{"uri":"data:application/pdf;base64,JVBERi0xLjQK","name":"a.pdf"}]}' >/dev/null
    $OC service stop >/dev/null 2>&1
    DB=$(ls -S "$HOME"/.local/share/opencode/opencode*.db | head -1)  # opencode.db, or opencode-local.db from source
    # Simulate the stored (e.g. migrated V1) message with a ~8 MB valid base64 PDF.
    python3 - "$DB" <<'E'
    import sqlite3,json,base64,sys
    c=sqlite3.connect(sys.argv[1])
    big=base64.b64encode(b'%PDF-1.4\n'+b'x'*6_000_000).decode()
    rows=c.execute("select id,payload from session_inbox where type='user'").fetchall()
    for i,p in rows:
      j=json.loads(p); j['files'][0]['data']=big
      c.execute("update session_inbox set payload=? where id=?",(json.dumps(j),i))
    for i,d in c.execute("select id,data from session_message where type='user'").fetchall():
      j=json.loads(d)
      if j.get('files'): j['files'][0]['data']=big; c.execute("update session_message set data=? where id=?",(json.dumps(j),i))
    c.commit()
    E
    $OC service start >/dev/null
    echo "before: $(python3 -c "import sqlite3;print(sqlite3.connect('$DB').execute(\"select project_id from session_v2 where id='$S'\").fetchone())")"
    $OC api post /api/session/$S/move --data "{\"directory\":\"$HOME/dest\"}"
    echo "move exit code: $?"
    sleep 5
    echo "after:  $(python3 -c "import sqlite3;print(sqlite3.connect('$DB').execute(\"select project_id,directory from session_v2 where id='$S'\").fetchone())")"
    echo "pending inbox: $(python3 -c "import sqlite3;print(sqlite3.connect('$DB').execute(\"select type,payload from session_inbox where session_id='$S'\").fetchall())" | cut -c1-200)"
    grep -rh "Failed to drain" "$HOME/.local/share/opencode" "$HOME/.local/state/opencode" 2>/dev/null | cut -c1-220
    $OC service stop >/dev/null 2>&1

    Run it: bash repro.sh

  2. Finn763 commented on Oct 21, 2026

    @Finn763
    Contributor

    I'd like to work on this — the move flow swallows the drain decode error and reports success. I'll surface the failure and keep the session unmoved.

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