reasoning: opencode zen (big-pickle) inline </think> leaks into text content instead of a reasoning part · Issue #54599 · anomalyco/opencode · GitHub
Skip to content

reasoning: opencode zen (big-pickle) inline </think> leaks into text content instead of a reasoning part #54599

Open

Description

@ashaker20

Summary

On OpenCode 2.0.25, using opencode/big-pickle via OpenCode Zen, the model's
chain-of-thought intermittently arrives inline in the assistant content
(sometimes terminated with a literal </think>). OpenCode stores the whole
string as a single text part, so the TUI renders the reasoning as ordinary
assistant output with no collapsible thinking block.

This is the V2 report of #43770 (closed as not planned because it was filed
against OpenCode 1). A comment there on Oct 11, 2026 reports the same symptom
with "opencode zen (big pickle)".

Environment

  • opencode version: 2.0.25
  • OS: Darwin 27.0.0 (darwin arm64)
  • Terminal: ghostty (TERM=xterm-256color, COLORTERM=truecolor)
  • Shell: /bin/zsh
  • Install/channel: release (standalone binary)
  • Active plugins: local plugins usage and fallback (both trivial; unrelated — the behavior predates them)

Reproduction

  1. In OpenCode 2.0.25, select opencode/big-pickle (OpenCode Zen) as the model.
  2. Run a multi-step agentic task that makes tool calls.
  3. Inspect assistant messages over the session. Intermittently, a message's
    content contains the model's reasoning monologue as a plain text part —
    often ending with a literal </think> immediately followed by the actual
    answer.

Expected Behavior

Inline reasoning (including ... delimiters) should be extracted into a
separate reasoning part and rendered as a collapsible thinking block, the same
way reasoning_content is handled.

Actual Behavior

The raw reasoning text (sometimes with a literal </think>) is stored verbatim
as a single text part in session_message and displayed as normal text.
Example of one such text part:

... need to do (3 steps: get keys, connect, use).
- The honest caveat about no auto-failover.

Let me include the verification evidence.

Done.</think>Done — Groq, Cerebras, and OpenRouter are wired in. Here's the state.

## What changed
...

So a single text part contains [reasoning] </think> [final answer], and the
message reports no reasoning part at all.

Additional Context

Measured across all assistant messages in ~/.local/share/opencode/opencode.db
(parsing session_message.data, counting text parts that contain a real
</think> not wrapped in backticks):

model text parts containing </think> ending with </think>
opencode/big-pickle (Zen) 152 104 23
opencode/space-bunny-free (Zen) 24 0 0
lmstudio/qwen3.6-35b-a3b-mlx (openai-compatible) 84 0 0

Notes:

  • Only the Zen big-pickle model shows this; the other models populate
    reasoning_content correctly and produce proper reasoning parts.
  • big-pickle also produces proper reasoning parts on other turns
    (62 of them), so the leak is intermittent per turn.
  • session.thinking: "hide" does not hide the leaked text, because it is a
    text part, not a reasoning part.
  • interleaved: { field: "reasoning_content" } is already set for big-pickle
    in the catalog, so this looks like an unhandled inline-<think> case rather
    than a missing mapping.
  • Also reproduced on a second machine running Windows with the same model/provider.
    Claude models on the same setup are unaffected (their reasoning uses the proper
    thinking channel), which points at inline reasoning in content specifically.

Related issues: #43770, #34126, #36387.

Suggested fix: run the AI SDK extractReasoningMiddleware({ tagName: "think" })
for the Zen big-pickle model (as is done for DeepSeek R1), or expose a
per-model option such as reasoning.extract: "think-tags".

Activity

  1. opencode-agent commented on Oct 11, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report and the database numbers. I was able to reproduce this.

    There are no real Zen credentials here, so I used a simulated OpenAI-compatible model in the TUI:

    • Turn 1: reasoning sent on the proper reasoning channel shows up as a collapsible Thought block, as expected.
    • Turn 2: reasoning sent inline in content, ending with a literal </think> and no opening tag, followed by the answer. OpenCode shows all of it, </think> included, as normal answer text, with no thinking block.

    It happens on 2.0.25 and still happens on the latest dev (38454c6). As far as I can tell, v2 only treats separate reasoning fields like reasoning_content as reasoning. Inline <think>/</think> tags in content are never split out, so this fits with big-pickle only sometimes sending its thinking inline.

    The recording and screenshots below show it, and the script below reproduces it.

    Related: #54298 (same model, reasoning saved as normal text in long sessions, likely the same cause), #54586 and #43770 (the same symptom on v1).

    recording.mp4

    Screenshot

    Screenshot

    Reproduction script (drive.ts)
    // Reproduces #54599: reasoning streamed inline in the assistant `content`
    // (ending with a literal </think>, as opencode/big-pickle sometimes does)
    // is stored and rendered as plain answer text instead of a reasoning block.
    import { Effect } from "effect"
    import { Llm, OpenCodeDriver } from "opencode-drive"
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true },
        project: { git: true, files: { "README.md": "# demo\n" } },
      },
      ({ ui, llm }) =>
        Effect.gen(function* () {
          // Turn 1: reasoning on the proper reasoning channel -> collapsible "Thought" block
          yield* llm.queue(
            Llm.reasoning("The user wants a status update. I should summarise briefly."),
            Llm.text("PROPER-ANSWER: reasoning above came via the reasoning channel."),
            Llm.finish(),
          )
          yield* ui.submit("Give me a status update")
          yield* ui.waitFor("PROPER-ANSWER")
          yield* ui.screenshot("turn1-proper-reasoning")
    
          // Turn 2: reasoning inline in content, terminated by </think> (no opening tag)
          yield* llm.queue(
            Llm.text(
              "INLINE-REASONING: I need to list the 3 steps: get keys, connect, use.\n" +
                "Let me include the verification evidence.\n\nDone.</think>Done — Groq, Cerebras, and OpenRouter are wired in.",
            ),
            Llm.finish(),
          )
          yield* ui.submit("Wire in the providers")
          yield* ui.waitFor("OpenRouter are wired in")
          yield* Effect.sleep("1 second")
          yield* ui.screenshot("turn2-inline-think-leak")
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

  2. 00kino547 commented on Oct 11, 2026

    @00kino547

    Hope this gets fixed soon because its annoying and its hard to ser an real output and a reasoning output because they are close

  3. ashaker20 commented on Oct 11, 2026

    @ashaker20
    Author

    Proposed fix ready: #54612

    It splits the inline chain-of-thought (everything before the trailing response marker) out of the leaked text part into a proper reasoning part at the projection boundary (session.text.ended in message-updater.ts), so it renders as a collapsible thinking block with the answer ordered after it. Text with no marker — or a marker with nothing before it — is left untouched.

    Added a focused unit test (packages/core/test/session-message-updater.test.ts). Happy to iterate on approach/location if maintainers prefer a stream-time reroute instead.

  4. leosper commented on Oct 13, 2026

    @leosper

    Confirmed against 2.0.26 (CLI/TUI, Windows 11, Windows Terminal), same model opencode/big-pickle#default via the opencode provider.

    In a single long session the behavior flips partway through:

    • Assistant messages 2-119: chain-of-thought stored as a proper reasoning part (state.reasoningField: "reasoning_content"), rendered as a collapsible Thought block.
    • From message 120 onward: no reasoning part is created. The CoT arrives inline in a text part, terminated by a literal response, followed by the real answer.

    Concrete example from opencode session export: an assistant text part of 17,737 characters containing only CoT, ending with ...Let me apply the edit. responseI found the actual cause.

    Session-wide counts: response appears 105 times in assistant text parts; real opening thinking tags: 0.

    The flip happens without any service restart or model change (same model/route throughout, continuous stream). The same pattern was also observed in a second session on the same model.

    As noted in the report, session.thinking: "hide" / mini.thinking: "hide" have no effect here because the content sits in text, not a reasoning part, so there is no client-side workaround.

  5. nick-o commented on Oct 14, 2026

    @nick-o

    Adding root-cause detail from a fresh repro (#55414, macOS, v2.0.20) that may help whoever reviews #54612.

    Across all assistant messages in an active opencode/big-pickle session:

    • opening thinking marker: 0 occurrences
    • closing </think> marker: 39 occurrences (every message)

    So the closing tag is the only delimiter present — the opening tag is never emitted at all.

    The existing tag extraction can't catch this for two independent reasons:

    1. It requires the opening marker. The streamed extractor only triggers on content.startsWith(" thinking"), and the batch splitter searches indexOf(" thinking"). With no opening marker, everything stays text.
    2. It's gated to one hardcoded model id. Tag-based splitting only runs when function UVo(e){return ["qwen3-4b"].includes(e)} is true — i.e. only for qwen3-4b, not for other models that emit the same inline format.

    A fix probably needs to (a) treat a lone closing </think> as "everything before it is reasoning", and/or (b) drop the model-id hardcoding. Happy to test once #54612 lands.

  6. jjdubski commented on Oct 14, 2026

    @jjdubski

    Can confirm this happens with Big Pickle model on latest opencode version v2.0.29

    Running on macOS

  7. glucmastersten-cpu commented on Oct 16, 2026

    @glucmastersten-cpu

    Adding the per-version numbers for the residual v2 shape here, as the triage on #56104 suggested.

    Environment: opencode CLI 2.0.29 → 2.0.31 (npm install), Windows, model opencode/big-pickle, the same machine and the same opencode.db for all rows. Counted on assistant messages in session_message (V2 schema), grouped by the version recorded in session_v2.version:

    opencode assistant msgs text part contains the closing marker msgs that have a reasoning part
    2.0.29 720 437 (60.7%) 92
    2.0.30 291 74 (25.4%) 241
    2.0.31 117 24 (20.5%) 50

    The 2.0.31 numbers are not only the "marker at the start of the reply" shape: for the affected messages there is no reasoning part at all — the whole thinking trace is persisted as an ordinary text part and simply ends with the literal marker.

    Real example from a session created after the 2.0.31 upgrade (20 assistant messages, 17 affected). Message content:

    [
      { "type": "text", "text": "Let me find where ai-transcribe skill lives. Probably in a skill dir ... Let's search.</think>" },
      { "type": "tool", ... },
      { "type": "tool", ... }
    ]
    

    No reasoning part, and the text part is pure chain-of-thought — that turn had no visible answer at all (the model called tools right after thinking). Since it is a normal text part, it is also replayed as assistant content in the next request, so the model sees its own thinking as a regular answer.

    So the residual 2.0.31 behaviour looks like the same root cause as in the issue: the inline reasoning boundary is only handled when the marker is at the start of the visible text, and not when the trace occupies the whole text part. If PR #54612 splits inline reasoning in the stream parser, it would be worth checking the split is applied to the entire text part (and not only to a leading marker).

    Numbers per version and shapes above are reproducible by reading the local session DB (session_v2, session_message) — happy to provide the exact queries if useful.

  8. Ramon-Az commented on Oct 17, 2026

    @Ramon-Az

    Comentário para colar no #54599

    Confirmo o mesmo padrão que vocês descrevem no #54599, com medições extraídas do banco local do OpenCode.

    Ambiente

    • OpenCode v2.0.32
    • Windows 11 Pro
    • Modelo: opencode/big-pickle (variants: [], custo 0 in/out)
    • cli.json com session.thinking: "hide" já definido

    O que observo

    Em sessões longas, a resposta chega do servidor já com o raciocínio embutido na parte text. Não existe parte type: "reasoning" separada para essas mensagens. O reasoning_content vem null e frequentemente não há tag ````think` de abertura.

    Medições (banco local opencode.db, 238 sessões do big-pickle)

    Schema: message, part (data JSON com type), session_message (type=assistant, data.content[]), session_v2 (model).

    Part types (todas as sessões): tool 28106 · step-start 26728 · step-finish 26078 · text 18894 · reasoning 14335 · patch 735 · compaction 248.

    measure value
    assistant messages (big-pickle) 21,181
    start with planning text 2,549 (12%)
    reasoning parts present 10,137

    Correlação que isola o sintoma

    day msgs with reasoning part msgs without hard leak with reasoning hard leak without
    2026-10-10 208 108 0 6
    2026-10-11 280 170 0 3
    2026-10-12 165 63 0 6
    2026-10-15 8 634 0 21
    2026-10-16 77 85 0 4
    2026-10-17 3 31 0 2

    hard leak with reasoning = 0 em todos os dias: quando a parte reasoning existe, o text está limpo; quando não existe, o planejamento aparece dentro do text. Em 2026-10-15 a relação inverte (8 com vs 634 sem).

    Workarounds testados localmente

    1. Regra de prompt rígida ("nunca emita raciocínio no corpo visível") — reduz, não elimina.
    2. Plugin local que detecta marcadores de planejamento na parte text e injeta aviso no system prompt — funciona como guarda pós-hoc, mas o v2 não tem hook para reescrever o texto visível (experimental.text.complete não existe mais), então o turno ofensivo já é enviado.

    Conclusão

    O raciocínio já chega do servidor embutido no content; não há configuração ou opção de provedor que se altere do lado do cliente. A correção proposta no #54612 (dividir o chain-of-thought embutido em uma parte reasoning no lado do cliente) é o caminho.

    Disponível para capturar fluxo bruto do provedor de uma rodada afetada, se for útil.

    --
    Autor: Ramon Azevedo

  9. Made-of-Clay commented on Oct 17, 2026

    @Made-of-Clay

    Still noticing this in 2.0.32 (also saw it on 1.18.35 before I upgraded) - here are my Big Pickle findings (Big Pickle analyzing itself... this is starting to sound bad 😅)

    opencode/big-pickle, agent build. Byte-confirmed marker 3c 2f 74 68 69 6e 6b 3e (</think>); one turn's content[0] is exactly the 8-char marker, another is [CoT] + marker + [answer] with no reasoning part. Also observe the mid-session flip (seq 5/16/29 parse as reasoning, then seq 42+ leak into text). Extends the residual-shape table past 2.0.31. No new root cause.

    Hope that's helpful. Love this tool!

  10. Bad0Weather commented on Oct 17, 2026

    @Bad0Weather

    Adding the per-session counts from my (now-closed-as-duplicate) report #56428, as requested, with the raw sqlite3 output below.

    Environment: opencode v2.0.31 (Arch Linux), model opencode/big-pickle (provider opencode, variant default, agent build).
    Session: ses_eb6044450ffeMO126Z5pJ5rhhK. Measured on session_message.data.content[] (V2 schema).

    Counts

    metric value
    assistant messages 63
    ... containing the close marker 61
    ... containing the open marker 12 (all quotations inside CoT text, not structural)
    content parts total 134
    parts typed reasoning 8
    parts typed text containing the close marker 61
    parts that are only the close marker (<=10 bytes: marker + \n) 3
    parts whose first bytes are the open marker 0

    Short turns correct, long turns leak

    In this session the split works only for the very smallest CoT blocks; everything larger leaks. Stored shape of a leaked turn is a single text part: <CoT></think><answer> — the close marker sits exactly on the CoT→answer boundary, but the CoT before it is typed text, and there is no opening marker () anywhere.

    seq content parts close-marker byte offset result
    7 reasoning:41 — correct (collapsible reasoning part)
    20 reasoning:219, text:217 — correct
    48 text:195 80 leaked
    35 text:1095 891 leaked
    57 text:2230 1509 leaked

    So the mid-session flip others described shows up here as a size threshold: seq 7 and 20 (CoT 41 / 219 chars) parse as reasoning; from seq 35 onward (CoT >= ~891 chars) nothing does, and the close marker leaks into text.

    Inconsistent split within a single turn

    seq 459 has text:7838 immediately followed by reasoning:574 — two consecutive chunks of the same CoT stream typed differently. Small reasoning fragments also appear on otherwise-leaked turns (seq 279:5, seq 430:9, seq 591:52), which look like parser artifacts rather than real reasoning parts.

    Global

    Across all sessions in the same DB: 364 parts typed reasoning vs 118 parts typed text containing the close marker.

    Raw sqlite3 output

    Queries (session ses_eb6044450ffeMO126Z5pJ5rhhK, read-only copy of ~/.local/share/opencode/opencode.db):

    .mode column
    .headers on
    SELECT
      (SELECT count(*) FROM session_message WHERE session_id='ses_eb6044450ffeMO126Z5pJ5rhhK' AND type='assistant') AS assistant_msgs,
      (SELECT count(*) FROM session_message WHERE session_id='ses_eb6044450ffeMO126Z5pJ5rhhK' AND type='assistant' AND instr(data, x'3c2f7468696e6b3e')>0) AS msgs_with_close,
      (SELECT count(*) FROM session_message WHERE session_id='ses_eb6044450ffeMO126Z5pJ5rhhK' AND type='assistant' AND instr(data, x'3c7468696e6b3e')>0) AS msgs_with_open,
      (SELECT count(*) FROM session_message sm, json_each(sm.data,'$.content') j WHERE json_valid(sm.data) AND sm.session_id='ses_eb6044450ffeMO126Z5pJ5rhhK') AS parts_total,
      (SELECT count(*) FROM session_message sm, json_each(sm.data,'$.content') j WHERE json_valid(sm.data) AND sm.session_id='ses_eb6044450ffeMO126Z5pJ5rhhK' AND j.value->>'type'='reasoning') AS parts_reasoning,
      (SELECT count(*) FROM session_message sm, json_each(sm.data,'$.content') j WHERE json_valid(sm.data) AND sm.session_id='ses_eb6044450ffeMO126Z5pJ5rhhK' AND j.value->>'type'='text' AND instr(j.value->>'text', x'3c2f7468696e6b3e')>0) AS text_parts_with_close,
      (SELECT count(*) FROM session_message sm, json_each(sm.data,'$.content') j WHERE json_valid(sm.data) AND sm.session_id='ses_eb6044450ffeMO126Z5pJ5rhhK' AND j.value->>'type'='text' AND length(j.value->>'text')<=10 AND instr(j.value->>'text', x'3c2f7468696e6b3e')>0) AS close_only_parts,
      (SELECT count(*) FROM session_message sm, json_each(sm.data,'$.content') j WHERE json_valid(sm.data) AND sm.session_id='ses_eb6044450ffeMO126Z5pJ5rhhK' AND substr(coalesce(j.value->>'text',''),1,7)=x'3c7468696e6b3e') AS parts_starting_with_open;
    
    SELECT '--- per assistant message: seq | part_type:length joined ---' AS note;
    SELECT sm.seq AS seq,
           group_concat(json_extract(j.value,'$.type') || ':' || coalesce(length(json_extract(j.value,'$.text')),0), ' | ') AS parts
    FROM session_message sm, json_each(sm.data,'$.content') j
    WHERE json_valid(sm.data) AND sm.session_id='ses_eb6044450ffeMO126Z5pJ5rhhK' AND sm.type='assistant'
    GROUP BY sm.seq ORDER BY sm.seq;
    
    SELECT '--- leaked text parts: seq | len | close_marker_byte_offset ---' AS note;
    SELECT sm.seq AS seq,
           length(json_extract(j.value,'$.text')) AS len,
           instr(j.value->>'text', x'3c2f7468696e6b3e') AS close_at
    FROM session_message sm, json_each(sm.data,'$.content') j
    WHERE json_valid(sm.data) AND sm.session_id='ses_eb6044450ffeMO126Z5pJ5rhhK' AND sm.type='assistant'
      AND json_extract(j.value,'$.type')='text' AND instr(j.value->>'text', x'3c2f7468696e6b3e')>0
    ORDER BY sm.seq;
    
    SELECT '--- global across all sessions ---' AS note;
    SELECT
      (SELECT count(*) FROM session_message sm, json_each(sm.data,'$.content') j WHERE json_valid(sm.data) AND j.value->>'type'='reasoning') AS reasoning_parts,
      (SELECT count(*) FROM session_message sm, json_each(sm.data,'$.content') j WHERE json_valid(sm.data) AND j.value->>'type'='text' AND instr(j.value->>'text', x'3c2f7468696e6b3e')>0) AS text_parts_with_close;

    Output:

    assistant_msgs  msgs_with_close  msgs_with_open  parts_total  parts_reasoning  text_parts_with_close  close_only_parts  parts_starting_with_open
    --------------  ---------------  --------------  -----------  ---------------  ---------------------  ----------------  ------------------------
                63               61              12          134                8                     61                 3                         0
                                note
    ------------------------------------------------------------
    --- per assistant message: seq | part_type:length joined ---
    seq                     parts
    ---  --------------------------------------------
      7  reasoning:41 | tool:0 | tool:0
     20  reasoning:219 | text:217 | tool:0 | tool:0
     35  text:1095 | tool:0 | tool:0
     48  text:194 | tool:0
     57  text:2223 | tool:0 | tool:0
     70  text:948 | tool:0 | tool:0
     83  text:2627 | tool:0
     92  text:8 | tool:0
    101  text:4318
    111  text:1114 | tool:0
    120  text:2050 | tool:0
    129  text:791 | tool:0
    138  text:1267 | tool:0
    147  text:818 | tool:0
    156  text:551 | tool:0
    165  text:3799 | tool:0
    174  text:8 | tool:0
    183  text:203 | tool:0
    192  text:147 | tool:0
    201  text:67 | tool:0
    210  text:1234 | tool:0
    219  text:284 | tool:0
    228  text:3577
    238  text:2277
    248  text:872 | tool:0
    257  text:1281 | tool:0 | tool:0
    270  text:462 | tool:0
    279  text:1616 | reasoning:5 | tool:0 | tool:0
    294  text:2326 | tool:0
    303  text:2833 | tool:0
    312  text:625 | tool:0
    321  text:11430 | tool:0
    330  text:743 | tool:0
    339  text:8 | tool:0
    348  text:205 | tool:0
    357  text:304 | tool:0
    366  text:858 | tool:0
    375  text:2011 | tool:0
    384  text:693 | tool:0
    393  text:3991 | tool:0
    402  text:2959 | tool:0
    411  text:9311 | tool:0
    420  text:8742
    430  text:2896 | reasoning:9 | tool:0
    441  text:1561 | tool:0
    450  text:3703 | tool:0
    459  text:7838 | reasoning:574
    471  text:4582 | tool:0
    480  text:611 | tool:0
    489  text:10165 | reasoning:327 | tool:0
    500  text:11367 | tool:0
    509  text:2257 | tool:0
    518  text:9512 | tool:0
    527  text:19832 | reasoning:559 | tool:0 | tool:0
    545  text:19384 | tool:0
    554  text:3751 | tool:0
    563  text:21590
    573  text:318 | tool:0
    582  text:968 | tool:0
    591  text:19765 | reasoning:52 | tool:0
    602  text:10683 | tool:0
    611  text:12192
    621  text:4933 | tool:0
                                 note
    ---------------------------------------------------------------
    --- leaked text parts: seq | len | close_marker_byte_offset ---
    seq   len   close_at
    ---  -----  --------
     35   1095       888
     48    194        79
     57   2223      1506
     70    948       776
     83   2627      2198
     92      8         1
    101   4318      2171
    111   1114       362
    120   2050      2043
    129    791       784
    138   1267      1260
    147    818       653
    156    551       459
    165   3799      3544
    174      8         1
    183    203       196
    192    147         1
    201     67         1
    210   1234      1133
    219    284       185
    228   3577      1282
    238   2277       248
    248    872       865
    257   1281      1274
    270    462       294
    279   1616      1497
    294   2326      2231
    303   2833      2695
    312    625       618
    321  11430      1371
    330    743       736
    339      8         1
    348    205       198
    357    304       297
    366    858       851
    375   2011      1858
    384    693       375
    393   3991       128
    402   2959      2794
    411   9311      9120
    420   8742      6148
    430   2896      1353
    441   1561       608
    450   3703      2753
    459   7838      5693
    471   4582       239
    480    611       604
    489  10165       102
    500  11367      1075
    509   2257       139
    518   9512       163
    527  19832      1865
    545  19384        91
    554   3751      3744
    563  21590       100
    573    318       311
    582    968        43
    591  19765        56
    602  10683        94
    611  12192        43
    621   4933      1082
                   note
    ----------------------------------
    --- global across all sessions ---
    reasoning_parts  text_parts_with_close
    ---------------  ---------------------
                364                    118
    
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