DSH plugin blank session: duplicate tool call ids break load

TroubleshootingPublished 2026-09-12Author: DeepSeek Plugin Market
DeepSeek HarnessDSH pluginblank sessionduplicate callIdreceived more than one start Match
A DeepSeek Harness session that refuses to open or opens blank usually shares one cause: a callId advertised twice in one step breaks the assembler.

When a historical session either refuses to open or opens with the entire conversation blank, the two very different symptoms often share one cause: the same callId advertised twice within a single (turn, step). On a v0 file it makes the v0-to-v1 migration refuse the whole session at load time (Mode A); on a file already at v2 it lets the host restore successfully while the client assembler throws received more than one start Match (Mode B). The root cause is that the provider-issued id and the harness-internal invocation identity share one field, callId: the write side never enforced uniqueness, while the read side treats it as a hard invariant.

DeepSeek Harness: Mode A cannot open, Mode B is blank

One corruption, two read paths, two failure surfaces. Work out which one you are on before choosing a remedy. What was measured:

  1. Mode A: the session will not open at all. The v0-to-v1 migration reuses the released validator assertReleasedArtifactRelationships. When it sees a tool-call block id in an assistant/message that was already advertised, it throws outright, and the whole migration — hence the session load — fails. The throw site is packages/session/session-format-v0-to-v1/src/relationships.ts:140: SessionFormatError('assistant/message repeats advertised tool call ${callId}') (#5909).
  2. Mode B: it opens but is blank. A session already at v2 (sample 81570fca: 1236 events, 45 duplicated-id groups) never traverses a migration edge; the production read path uses validation: 'transformed' (packages/session/session-persistence-jsonl/src/index.ts:276-280), which tolerates duplicates, so the host-side restore succeeds (about 22 ms). But the client's ConversationNodeAssembler throws the same message at assembler.ts:513 and :569, leaving the conversation entirely blank with only a "Load earlier" button; clicking it runs prepend, throws again, resets the button, and content never appears.
  3. The raw console error in the front end: [session-controller] event feed subscriber failed: Error: conversation Context 20:trajectory-tool-callpwsh:0 received more than one start Match — the feed subscriber for the entire event stream breaks on it (#5247).
  4. The corruption can lie completely dormant: once persisted, no fresh model output is needed — for a v2 session, simply reopening it trips the failure; for a v0 session you cannot even enter the migration. File damage can be ruled out: the inspected log had all 7360 frames decompress successfully, 8907 events, seq continuous to 37098, and every JSONL line valid (#5247).
  5. Severity differs by version: 46196d6f95 (in 0.1.3-alpha.2) removed the 'target' validation mode, so assertReleasedArtifactRelationships now also runs on 'current' restoration, meaning both migrated input and strict-current verification refuse these logs; production daily reads still use validation: 'transformed', so the session itself still opens (#5909).

DeepSeek Harness mechanism: the assembler keys by (kind, callId)

Both mechanisms are short, but together they explain the asymmetry — a released writer can produce what a released reader must refuse. Layer by layer:

  1. The front-end Context key has no turn dimension: acceptMatch keys by conversationContextKey(definition.kind, id) where id is the event's callId; if role === 'start' and the Context already has a start, it throws. The test suite pins this as designed behaviour (rejects a duplicate start before mutating the existing Context).
  2. Older writers minted short index-based ids: current agents use a unique uuid per tool call, so (kind, id) never collides by construction; older logs came from writers minting pwsh:0, grep:0, read:0 that are unique only within a turn. In one 16-turn session the measured collisions were pwsh:0×45, grep:0×11, read:0×8, grep:1×5, pwsh:1×7, write:0×4, edit:0×3, todo_write:0×4 (#5247).
  3. Cross-turn reuse is legitimate; same-turn repetition is malformed: the discriminating axis is (turn, step) — a duplicate start landing inside the same (turn, step) is genuinely malformed, while a short id reused across turns is legitimate historical data. That is also why the same id reused across distinct steps that each resolve their own lifecycle passes validation.
  4. The id-production chain has no de-duplication: a local OpenAI-compatible Responses server re-emits the same call_id+id for multiple output items within one assistant response; pi-ai composes ${call_id}|${id} with no uniqueness check, and packages/llm/llm-pi-ai/src/stream.ts:188,200 passes event.toolCall.id / known?.id through verbatim.
  5. No write-side constraint, hard read-side validation: the v0 format enforces no tool-call-id uniqueness at write time, while the released validator treats a repeated advertised tool call as an invariant violation (relationships.ts:140, alongside :156, :171, and assertNoUnresolvedTools). That asymmetry is the root cause that lets the corruption be produced and later refused.
  6. The data shape is fixed and recognisable: tool-call ids look like call_<token>|fc_<token>; each duplicated id occurs exactly twice inside a single step/turn; and one execution appears on four surfaces — the assistant/message content block, tool/call, tool/result (message.source.callId and message.content[0].toolCallId), and assistant/chunk (the block's deltas and block-end). A rename must touch all four surfaces in lock-step, or they stay inconsistent.**
  7. It surfaces elsewhere too: the same root cause shows up as a 400 from a strict provider when replaying sessions (#5732), as a client wedge received more than one start Match (#5884 / #4501 / #5296) and as received an update before its start Match (#5692), and server-side as BlockAssembler merging two distinct invocations by index (#4427) — one corruption, several surfaces.

DSH plugin repair tooling and write-side de-duplication

Treatment splits three ways: de-duplicate on the write side so new cases cannot occur, tolerate on the read side to rescue existing ones, and degrade gracefully in the client so the conversation does not blank. Concretely:

  1. Write-side de-duplication (the actual fix): de-duplicate or remap tool-call ids that repeat within a step, in the pi-ai adapter or the session writer, so the corruption cannot be persisted. A more thorough direction separates providerCallId (for protocol round-trip and diagnostics) from a harness-generated invocationId (for session pairing and assembler identity) into two fields — new writes then never collide, and the reader-side sibling fork degrades into a pure legacy-compatibility path (#5268).
  2. Reader-side tolerance: the community patch replaces the hard throw with sibling forking — a families map tracks base business keys to sibling Context keys, a repeated start forks base#2, base#3, and so on, and non-start matches route to the newest sibling whose startSeq <= event.seq; the prepend batch path segments per start so all three paths (append, window replay, prepend) land correctly (the full ui-conversation suite passes, 337 tests). One implementation detail: do not assume # cannot occur inside a provider-emitted id; use a separator such as NUL or a per-family monotonic counter, since the suffix only needs to be unique, not parseable.
  3. Graceful client degradation: on "one context, multiple starts", ConversationNodeAssembler should log and skip the offending block instead of throwing and blanking the entire conversation; the same degradation idea applies to received an update before its start Match (#5692).
  4. Disk-side id rename: dsh-session-surgeon's inspect marks duplicate-tool-call-id, and when the local SESSION_FORMAT_VERSION >= 1 (migrations apply from 0.1.3) its --apply suffixes the later ids with #n and remaps tool/call.callId and tool/result's source.callId in order of appearance, keeping the first id as-is and leaving empty callIds alone. The rename must stay pure — rename only, never insert or delete events — so seq stays dense and every sourceEventSeqs / surfaceOp reference remains valid (#5909).
  5. How to use the repair tool correctly: stop the writer first, run --dry-run first, and never apply repeatedly to an already-pruned file. The tool explicitly does not perform the v0-to-v1-to-v2 migration (that belongs to official persistence) and does not fix Mode B's blank front end. It needs a restart after installation:
sh
dsh plugin --profile web add "github:xiaoshenming/dsh-session-surgeon#main"
  1. Install plugins through the market: add and update repair plugins like this from DSH Plugin Hub under Settings, Plugin Market, which is safer than hand-editing the profile because a failure rolls the manifest back. For other shapes of session-content corruption (enumeration failures, format refusals), see session corruption troubleshooting.

DSH plugin troubleshooting notes

Tell mode A from mode B first — refusing to open usually means a v0 file rejected by the migration, while a blank-but-open session is a v2 file hitting the client assembler throw, and the remedies differ. Six points to keep in mind when a DeepSeek Harness plugin session will not render:

  1. Tell A from B first: refusing to open usually means a v0 file refused by the migration; opening blank means a v2 file hitting the client assembler throw. The remedies differ.
  2. Rename all four surfaces together: assistant/message, tool/call, tool/result, and assistant/chunk; missing one leaves the data inconsistent.
  3. Keep the rename pure: do not insert or delete events, or you disturb seq and the reference relationships.
  4. transformed is not a master key: it admits files already at v2 but cannot bypass the relationship validation on the v0 migration path.
  5. llama.cpp-class local servers are a high-incidence source: parallel tool calls in one response share an id, which is why "everything works while the session runs, and then you cannot continue it" is the typical timeline.
  6. Do not treat relaxed read validation as a complete fix: write-side de-duplication, explicit recovery of existing corruption, and client rendering need to be verified separately.
DSH Plugin Hub plugin market: install session repair plugins, check versions and updates

Sources: Discussion #5247, Discussion #5909, dsh-session-surgeon.

FAQ

In DeepSeek Harness, why does one historical session refuse to open while another opens with the whole conversation blank?

In DeepSeek Harness the two symptoms diverge because the same duplicated-id corruption lands on two different read paths. **Mode A** is a v0 file: the v0-to-v1 migration refuses the whole session at load time (assistant/message repeats advertised tool call <id>), so it cannot open at all. **Mode B** is a file already at v2: it never traverses a migration edge, the host restores it successfully under the permissive configuration, and yet the web client's ConversationNodeAssembler throws when one context receives two starts, leaving the conversation entirely blank with a wedged "Load earlier" (source: Discussion #5909).

How do duplicated callIds end up written into the session log in DeepSeek Harness?

In DeepSeek Harness the duplicated callIds come from a local OpenAI-compatible Responses server (llama.cpp-class) that re-emits the **same** call_id and id for multiple output items **within one assistant response**. pi-ai composes the tool-call id as ${call_id}|${id} (shaped call_<token>|fc_<token>) with no de-duplication, and this repository's adapter layer (packages/llm/llm-pi-ai/src/stream.ts:188,200) passes the id through verbatim. The v0 format enforces **no** tool-call-id uniqueness at write time, so the corrupt data is persisted happily, while the read side treats it as an invariant violation (source: Discussion #5909).

Why does the DeepSeek Harness assembler throw on a duplicate start instead of skipping it?

The DeepSeek Harness assembler throws on a duplicate start because ConversationNodeAssembler keys a Context by (definition.kind, callId): in acceptMatch, if role === 'start' and the Context already has a start, it throws received more than one start Match, and the test suite pins that rejection as intended behaviour. Current agents mint a unique uuid per tool call, so new writes never collide; older writers minted **short index-based ids** (pwsh:0, grep:0) that are unique only within a turn — across turns the same pwsh:0 names a different invocation — and since the assembler has no turn dimension it mistakes the second turn's pwsh:0 for a duplicate start (source: Discussion #5247).

Can a DeepSeek Harness session that is already corrupted be rescued?

A corrupted DeepSeek Harness session can be rescued along two routes. (1) **Reader-side tolerance**: the community patch replaces the hard throw with **sibling forking** — a repeated start forks a suffixed sibling Context and non-start events route to the newest sibling whose startSeq <= event.seq, covering append, window replay, and prepend (the full ui-conversation suite passes, 337 tests). (2) **Disk-side id rename**: dsh-session-surgeon's inspect marks duplicate-tool-call-id, and when SESSION_FORMAT_VERSION >= 1 its --apply suffixes the later ids with #n and remaps the references in order, keeping the first id untouched. Note that it does not perform the v0-to-v1-to-v2 migration itself, nor does it fix the blank-conversation half (source: Discussion #5909).

Related Terms

Mode A / Mode B
Two failure surfaces of the same duplicated-id corruption. Mode A is a v0 file refused wholesale by the v0-to-v1 migration at load time (the session cannot open); Mode B is a v2 file that the host restores fine while the web client assembler throws, blanking the whole conversation.https://github.com/deepseek-ai/deepseek-harness/discussions/5909
Context key (kind, callId)
The key the client conversation assembler uses to group one tool invocation into a Context, composed from the node's kind and the event's callId. It carries no turn or step dimension, so short index-based ids reused across turns collide in it.https://github.com/deepseek-ai/deepseek-harness/discussions/5247
sibling forking
The reader-side tolerance for repeated starts: a families map records base business keys to sibling Context keys, a repeated start forks `base#2`, `base#3`, and so on, and non-start events route to the newest sibling whose startSeq is not later than the event.https://github.com/deepseek-ai/deepseek-harness/discussions/5247

Sources