DeepSeek Harness 400: lone surrogates, empty blocks
In DeepSeek Harness (DSH), the most misjudged class of failure is "400 on every turn": the error looks like a model or network problem, opening a new session fails just the same, and restarting does not help — because the poison is not in this one request, it is in the history, and the history is resent in full every turn. This batch of discussions splits the family into four independent kinds of "invalid request body": illegal Unicode (an outbound message containing a lone surrogate, or the read tool truncating by UTF-16 code unit and splitting an emoji into a lone high surrogate); empty content blocks (a reasoning block persisted with text: "" when the model returned no reasoning content, or the adapter putting a pure-whitespace text block like {"text":"\n"} into the replay); a content-moderation hit (Content Exists Risk, a content property + resent every turn = permanently locked); and a reference-parser false hit (a dsh-session: plus a run of digits in a message is treated as a session reference and decoded, and a failure throws for the whole turn). The shared test for all four is: the error contains a searchable literal, yet syntax checks such as JSON.parse all pass (#9089, #8846, #9435, #8830, #9613).
DeepSeek Harness illegal Unicode: lone surrogates and code-unit truncation
In one sentence: JavaScript string operations count by UTF-16 code unit, an emoji takes two code units, and once the truncation point lands in the middle it produces a lone surrogate; it is written to disk as valid JSON (\ud83d), but the server's content validation judges it semantically invalid, so the whole turn is rejected. Both sources have really occurred: external content carrying a bad code unit, and DSH's own read tool truncating (#9089, #8846).
DeepSeek Harness mechanism: why "syntax-valid" still fails
JSON.stringifywrites a lone surrogate as an escape sequence like\ud83d— this is completely legal at the JSON syntax level, so anyJSON.parsecheck reports no error;- The server's Messages endpoint does a content check (Unicode validity), not a syntax check, so the whole request is rejected with a 400 and no locator information;
- Once the bad value enters session history it is resent with the history every turn, hence "the session fails forever, and a new session fails too";
- What the code side lacks is entry validation:
llm-deepseek'sbuildMessagesdoes only structural validation, with no Unicode validity check; thereadtool's truncation constant is 2000 code units (not code points), split withString.prototype.substring, and cutting in the middle of an astral character produces a lone high surrogate (#8846).
DSH plugin three-step locate: the bad value is "visible ASCII" in the file
- Rough-scan the session file for lone surrogates (they land on disk as plaintext escapes you can regex directly):
Select-String -Path "<session file>" -Pattern '\\u[dD][89abAB][0-9a-fA-F]{2}' -AllMatches |
ForEach-Object { $_.Matches } | Group-Object Value | Sort-Object Count -Descending |
Select-Object -First 10 Count, Name
# expect: it lists suspicious high-surrogate escapes such as \ud83d
- Confirm it is really unpaired: a high surrogate (
\uD800–\uDBFF) must be immediately followed by a low surrogate (\uDC00–\uDFFF). If what follows is not a low surrogate, it is a lone surrogate (#9089). - Confirm the source: if the bad value appears in the
readtool's return and its position is right around the 2000th code unit, you can be fairly sure it was a truncation splitting a surrogate pair (#8846).
DeepSeek Harness fix: replace the bad code unit and re-encode only its frame
- Back up the session file, then process it "line by line": copy the whole file →
JSON.parseeach line → scan for unpaired surrogate code units → replace withU+FFFD→ re-encode only the frame it lives in, without renumbering (#8846). - After re-encoding, scan once more to confirm no lone surrogates remain, then write back.
- State the cost honestly: replacing with
U+FFFDmeans half an emoji becomes�, which is a lossy fix; the thread also mentions a community offline tool that can do this kind oflone-surrogatehandling (#9089). - Stop the bleeding before fixing: if some plugin injects bad content into the history every turn, you must first stop that injection source, or it recurs right after the fix (#9089).
- The code-side proper fix: normalize once at the entry with
text.toWellFormed(), or make the truncation code-point-aware (#9089).
DeepSeek Harness empty content blocks: empty reasoning and pure-whitespace text
In one sentence: when the model returns no reasoning content, the Harness still persists a reasoning block with an empty text; the adapter then unconditionally serializes every reasoning block into a native thinking field, and the empty block sent as thinking:"" is rejected — turning thinking off cannot save it either, because the bad block is in history. The other shape is a pure-whitespace text block ({"type":"text","text":"\n"}) wedged between two tool calls, which makes the cloud API split the message (#9435, #9239, #8836).
DeepSeek Harness three error shapes and their mechanisms
| Error | Trigger | Mechanism |
|---|---|---|
The content[].thinking in the thinking mode must be passed back to the API | thinking mode + an empty reasoning block in history | the empty block is serialized as thinking:"" |
thinking type must have non-empty thinking field | switching to a strict Anthropic Messages-compatible gateway | the gateway validates empty thinking more strictly |
tool_use ids were found without tool_result blocks | cloud endpoint + a pure-whitespace text block in history | the whitespace block splits the message, and the previous tool's tool_use loses the tool_result that immediately followed it |
According to community source-level confirmation, the key gap on the code side is "the asymmetry of the drop-empty-block decision": interruptedBlocks() drops empty blocks, while the keep/drop decision in assembled() does not; meanwhile assistant() cannot get the thinking policy, so setting reasoningEffort to off also cannot change the serialization of empty blocks in history (#9435). On the whitespace-text side, the text branch of serialize.ts has no empty-block guard, and the suggested fix is to change the check to text.trim().length === 0 (#8836).
DeepSeek Harness four-step fix: block-level delete plus one invariant you must obey
- Locate the empty blocks: search the session file for empty thinking / reasoning content:
Select-String -Path "<session file>" -Pattern '"thinking":""|"text":""|"text":"\\n"'
# expect: a few hits, which are the empty blocks
- Back up, then do a block-level delete (no renumbering needed — this is where it is easier than the Unicode class) (#9239).
- At the same time delete the entry at the same position in
replayState.blocks— this is the most important invariant here: if you only delete the message block without touching the index,assemblerdrops the whole envelope for a count mismatch, throwing the signature away with it, and the failure escalates from "one block is broken" to "the whole segment is broken" (#9239). - Prevention side: the community plugin
@argszero/dsh-empty-block-guardwraps the return value ofllm/streamto stop new empty blocks from landing on disk:
npm i @argszero/dsh-empty-block-guard
Boundary: the prevention plugin can only prevent "new blocks"; already-poisoned logs must be fixed offline. Also be careful not to write "drop empty blocks" too broadly — a naive
||check will wrongly hit blocks that carry tool calls (#9435).
DeepSeek Harness moderation lock: content property + resending every turn = permanent failure
In one sentence: Content Exists Risk is a content property, not an entry-point property, and because the history is resent in full every turn, one record that crosses the line makes the session fail forever — even copying the session carries the poison. The thread summarizes the family as "two mechanisms, three poison sources, three faces", and confirms there is also a response-side kind of refusal (MALFORMED_RESPONSE) that recurs every turn (#8830).
DeepSeek Harness three-step handling
- First judge whether the session is alive by
turn/end: receivingturn/endmeans the turn ended normally (a response-side stream cut / refusal), while not receiving it and only getting a 400 means the request side was rejected. The two paths are handled differently (#8830). - The only verified recovery path is "fork before the poison": fork a new session at the position before the error, leaving the offending stretch of history behind in the original session (#8830).
- Try to earn automatic recovery: add the corresponding error code to that profile's
retryableCodes, or use a community plugin that maps refusals to retryable, giving the request-side 400 a chance to continue after a narrowed resend; the thread also mentions a community plugin that narrows and resends incrementally to keep going (#8830).
Key point: for the moderation class, never expect "just switch the entry point" to work — opening a new session and carrying the history over is moving the poison along with you.
DeepSeek Harness reference-parser false hit: dsh-session: in a message kills the whole turn
In one sentence: the session-reference parser treats any bare text of "dsh-session: plus a run of alphanumerics" as a reference and tries to decode it; plain digits are a legal form too, and a failed decode throws, while the call site has no try/catch, so the whole turn fails. Even pasting the error text that contains that literal into a help request fails the same way (#9613).
According to the reporter's source check: prepareDirectMessages() in @deepseek-ai/dsh-session-reference calls parseSessionReferenceText on each user message's text block; the bare-text branch of the regex matches "scheme + [A-Za-z0-9_-]+", plain digits are legal → base64url decode, and a failed JSON.parse throws SESSION_REFERENCE_INVALID_REFERENCE; the four versions and master are byte-for-byte unchanged (#9613).
Handling steps:
- Locate: search the message text for
dsh-session:; a hit followed by an ordinary number / short string is the false hit. - Short-term avoidance: escape or rewrite that prefix (for example insert a space, or use a full-width colon) so the parser does not recognize it.
- Mitigation plugin: the community plugin
@argszero/dsh-session-reference-guardregisters on the same waterfall withprepend: true, rewriting only the literal that would be rejected; the author himself labels it a stopgap, not a fix (#9613). - Proper fix direction: this layer should fail-open — a failed parse should be treated as ordinary text rather than failing the whole turn.
DSH plugin troubleshooting notes
The mnemonic for "400 every turn" is: first use the error text as a keyword to search the logs, and if you find it you have located it directly; if you cannot, then judge by "does a new session fail too" whether the poison is in the history or in the current input. Nine points:
- A passing
JSON.parsecheck ≠ valid content: a lone surrogate's escape sequence is completely legal syntactically, so do not use it to rule the problem out (#8846). - The bad value is usually visible ASCII in the file, and a regex rough-scan is far faster than reading by hand (#9089).
- Truncation and slicing must be by code point: anywhere that cuts a string by "length" can split a surrogate pair, and this is the family's manufacturing machine (#8846).
- Deleting an empty block requires deleting
replayState.blocksin step: this is the family's most expensive invariant, and forgetting it amplifies a small failure into a whole-segment failure (#9239). - Turning thinking off cannot cure the history's disease: the empty blocks in history are still serialized (#9435).
- For the moderation class, remember the words "content property": switching entry points and copying sessions both carry the poison, and only forking can save it (#8830).
dsh-session:is a literal trigger, unrelated to whether the session actually holds a reference (#9613).- Always back up before fixing: all four fixes edit session files directly, and a backup is the only way back.
- State the fix status honestly: all four are unfixed in the versions the discussions refer to; the available means are "offline cleanup of already-poisoned logs + plugins to prevent new blocks", which is user-side handling, not an official fix.
Troubleshooting this kind of problem often requires first confirming "is some plugin injecting bad content" — especially for the empty-block and reference-false-hit classes, where the injection source is often a third-party plugin. If you want to see plugin installs/uninstalls and logs in a UI first, rather than typing commands in various places, use a visual plugin marketplace such as DSH Plugin Hub: the installed list, install/uninstall history, and system logs are collected in one panel, and locating "who is writing bad things into the history" is much faster.

Source: Discussion #9089, Discussion #8846, Discussion #9435, Discussion #9239, Discussion #8836, Discussion #8830, Discussion #9613.
FAQ
In DeepSeek Harness, the broken record in an every-turn 400 is found by first checking whether the error contains a searchable literal. For the lone-surrogate class the log shows an escape like \ud83d written directly into the JSON (it is valid JSON but semantically invalid, so a JSON.parse check cannot find it); you can regex-scan the session file for unpaired \uD800-\uDFFF. The fix replaces the bad code unit with U+FFFD, or runs text.toWellFormed() once to normalize, then re-encodes only the frame it lives in without renumbering.
In DeepSeek Harness, this error comes from the empty reasoning blocks already persisted in history, not the current call. When the model returns no reasoning content the Harness still persists a reasoning block with an empty text, and the adapter unconditionally serializes every reasoning block into a native thinking field, so the empty block is sent as thinking:"" and rejected. Turning thinking off only affects new requests and cannot change the empty blocks already in history, so you must clean them offline.
In DeepSeek Harness, thinking type must have non-empty thinking field means the same empty thinking blocks, but this kind of gateway validates more strictly. There is one invariant you must obey: when you delete an empty reasoning block you must also delete the entry at the same position in replayState.blocks, otherwise the assembler drops the whole envelope for a count mismatch, signatures included. For existing sessions a block-level delete is enough, with no renumbering needed.
DeepSeek Harness does have a way out of Content Exists Risk, but you must accept the premise of forking before the poison point. Content moderation is a property of the content, not the entry point, and history is resent in full every turn, so one record that crosses the line makes the session fail forever. The verified recovery path is to fork a new session at a position before the offending record; you can also add the corresponding error code to that profile's retryableCodes, or use a community plugin that maps refusals to retryable, to earn automatic recovery.
In DeepSeek Harness the session-reference parser treats any bare text of dsh-session: plus a run of alphanumerics as a reference and tries to decode it. A scheme plus plain digits is also a legal form; decoding followed by a failed JSON.parse throws SESSION_REFERENCE_INVALID_REFERENCE, and the call site has no try/catch, so the whole turn fails. Mitigations are to avoid that prefix in message bodies, or use a community plugin that escapes the literal that would be rejected; the real fix is to make this layer fail-open.
Related Terms
- lone surrogate
- A lone surrogate is an unpaired surrogate code unit in UTF-16 (one that appears alone among U+D800–U+DFFF) and cannot form a legal code point. In JSON it can legally be written as an escape sequence like \ud83d, but it is semantically invalid, so the server's content validation rejects it and returns an HTTP 400.— https://github.com/deepseek-ai/deepseek-harness/discussions/9089
- toWellFormed()
- String.prototype.toWellFormed() is the JavaScript method that replaces every lone surrogate in a string with the U+FFFD replacement character, normalizing any string into a legal Unicode sequence. It is the most direct tool for fixing the "lone surrogate poisons the session" class of problem.— https://github.com/deepseek-ai/deepseek-harness/discussions/9089
- replayState.blocks
- replayState.blocks is the index of replayed history content blocks in a DeepSeek Harness session log, mapping one-to-one by position to the message blocks. If you delete a message block without updating it in step, the count becomes inconsistent and the assembler discards the whole envelope.— https://github.com/deepseek-ai/deepseek-harness/discussions/9239
- Content Exists Risk
- Content Exists Risk is the refusal code returned by server-side content moderation, meaning the request content crossed the line. Because it is resent with the history every turn, a single over-the-line record can make the session fail forever, and recovery is to fork the session to a point before the poison.— https://github.com/deepseek-ai/deepseek-harness/discussions/8830
Sources
- #9089 — Outbound messages with a lone surrogate 400 with no locator, and once history is poisoned the session fails forever· deepseek-ai (GitHub Discussions)
- #8846 — The read tool truncating by UTF-16 code unit splits surrogate pairs, producing invalid JSON and a permanently 400ing session· deepseek-ai (GitHub Discussions)
- #9435 — Empty reasoning blocks are persisted and replayed, causing HTTP 400 errors· deepseek-ai (GitHub Discussions)
- #9239 — Empty reasoning blocks in history serialize into empty thinking blocks, permanently breaking existing sessions after moving to a strict Anthropic Messages gateway· deepseek-ai (GitHub Discussions)
- #8836 — Adapter: assistant() forwards empty text blocks → HTTP 400 from cloud endpoints· deepseek-ai (GitHub Discussions)
- #8830 — Session permanently locked / 400 every turn: the two mechanisms of Content Exists Risk and INVALID_REQUEST, and three poison sources· deepseek-ai (GitHub Discussions)
- #9613 — When a user message contains the dsh-session: prefix the agent/pre-step parser throws and the whole turn fails· deepseek-ai (GitHub Discussions)