DSH plugin blank session: duplicate tool call ids break load
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:
- Mode A: the session will not open at all. The v0-to-v1 migration reuses the released validator
assertReleasedArtifactRelationships. When it sees atool-callblock id in anassistant/messagethat was already advertised, it throws outright, and the whole migration — hence the session load — fails. The throw site ispackages/session/session-format-v0-to-v1/src/relationships.ts:140:SessionFormatError('assistant/message repeats advertised tool call ${callId}')(#5909). - 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 usesvalidation: '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'sConversationNodeAssemblerthrows the same message atassembler.ts:513and:569, leaving the conversation entirely blank with only a "Load earlier" button; clicking it runsprepend, throws again, resets the button, and content never appears. - 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). - 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).
- Severity differs by version:
46196d6f95(in 0.1.3-alpha.2) removed the'target'validation mode, soassertReleasedArtifactRelationshipsnow also runs on'current'restoration, meaning both migrated input and strict-current verification refuse these logs; production daily reads still usevalidation: '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:
- The front-end Context key has no turn dimension:
acceptMatchkeys byconversationContextKey(definition.kind, id)whereidis the event'scallId; ifrole === 'start'and the Context already has astart, it throws. The test suite pins this as designed behaviour (rejects a duplicate start before mutating the existing Context). - 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 mintingpwsh:0,grep:0,read:0that are unique only within a turn. In one 16-turn session the measured collisions werepwsh: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). - 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. - The id-production chain has no de-duplication: a local OpenAI-compatible Responses server re-emits the same
call_id+idfor multiple output items within one assistant response; pi-ai composes${call_id}|${id}with no uniqueness check, andpackages/llm/llm-pi-ai/src/stream.ts:188,200passesevent.toolCall.id/known?.idthrough verbatim. - 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, andassertNoUnresolvedTools). That asymmetry is the root cause that lets the corruption be produced and later refused. - 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 — theassistant/messagecontent block,tool/call,tool/result(message.source.callIdandmessage.content[0].toolCallId), andassistant/chunk(the block's deltas and block-end). A rename must touch all four surfaces in lock-step, or they stay inconsistent.** - 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 asreceived an update before its start Match(#5692), and server-side asBlockAssemblermerging 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:
- 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-generatedinvocationId(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). - Reader-side tolerance: the community patch replaces the hard throw with sibling forking — a
familiesmap tracks base business keys to sibling Context keys, a repeated start forksbase#2,base#3, and so on, and non-start matches route to the newest sibling whosestartSeq <= 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. - Graceful client degradation: on "one context, multiple starts",
ConversationNodeAssemblershould log and skip the offending block instead of throwing and blanking the entire conversation; the same degradation idea applies toreceived an update before its start Match(#5692). - Disk-side id rename:
dsh-session-surgeon'sinspectmarksduplicate-tool-call-id, and when the localSESSION_FORMAT_VERSION >= 1(migrations apply from 0.1.3) its--applysuffixes the later ids with#nand remapstool/call.callIdandtool/result'ssource.callIdin 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 — soseqstays dense and everysourceEventSeqs/surfaceOpreference remains valid (#5909). - How to use the repair tool correctly: stop the writer first, run
--dry-runfirst, 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:
dsh plugin --profile web add "github:xiaoshenming/dsh-session-surgeon#main"
- 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:
- 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.
- Rename all four surfaces together:
assistant/message,tool/call,tool/result, andassistant/chunk; missing one leaves the data inconsistent. - Keep the rename pure: do not insert or delete events, or you disturb
seqand the reference relationships. transformedis not a master key: it admits files already at v2 but cannot bypass the relationship validation on the v0 migration path.- 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.
- 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.

Sources: Discussion #5247, Discussion #5909, dsh-session-surgeon.
FAQ
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).
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).
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).
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
- deepseek-harness Discussion #5247: opening a historical session fails with "received more than one start Match" when the same callId appears in a second tool/call· deepseek-ai (GitHub Discussions)
- deepseek-harness Discussion #5909: Duplicate Tool-Call IDs Break Session Loading (blank session) and cause migration v0-to-v1-to-v2 failures· deepseek-ai (GitHub Discussions)
- dsh-session-surgeon: inspect flags duplicate-tool-call-id, --apply suffixes later ids with #n· GitHub (xiaoshenming)