feat: DSML tool-call recovery plugin for OpenCode V2

Recovers DeepSeek DSML tool calls that leak as assistant text: streaming
middleware (AI-SDK path), SSE rewrite (native path), history sanitization,
preventive directive, conditional system nudge, and a capped idle watchdog.
116 tests, synthetic golden fixtures.
This commit is contained in:
bendtherules
2026-10-05 11:47:57 +05:30
commit 52b7776d5a
26 changed files with 4600 additions and 0 deletions
+124
View File
@@ -0,0 +1,124 @@
# DSML Tool-Call Pattern Catalogue
Single source of truth for what the parser **accepts**, what it **repairs**, what it
**rejects**, and which test proves each row. Every regex in `src/grammar.ts` must trace
to a row here. Add a row and a test before adding a pattern.
> Writing rule for this file: never paste a live DSML token. The model may emit it and
> our own harness (or the provider's greedy extractor) will turn this document into a
> phantom tool call. Represent tokens with the placeholder `‹DSML›` and show the
> literal bytes as `U+FF5C` escapes. The parser tests are the place for real bytes.
## 0. Notation
- `BAR` = one full-width bar `U+FF5C` (`|`) **or** one ASCII pipe `|`.
- `DSML` literal word.
- Canonical marker: `< BAR DSML BAR >` — e.g. `<‹DSML›tool_calls>`.
- Doubled marker (observed): `< BAR BAR DSML BAR BAR >`.
- In real bytes: `\uFF5C` repeated.
## 1. Canonical grammar
```
< MARKER tool_calls>
< MARKER invoke name="TOOL">
< MARKER parameter name="KEY" string="true">VALUE</ MARKER parameter>
< MARKER parameter name="KEY" string="false">JSON</ MARKER parameter>
</ MARKER invoke>
</ MARKER tool_calls>
```
- Outer block names: `tool_calls` (V4), `function_calls` (V3.2), and the mangled `calls`.
- Single or doubled bars; optional whitespace around the marker.
- `string="true"` → raw string value. `string="false"` → JSON value. Attribute absent →
raw string (default). Quotes may be `"` or `'`.
- Invoke/param names may be quoted with `"`/`'` or be a bare token.
## 2. Leak / degradation variants (the real-world enemies)
These are the shapes observed in the wild, in the order a stream degrades. Source tags:
`[DB]` = captured in the user's OpenCode session DB, `[vLLM]` = vLLM PR #54686,
`[OpenClaw]` = openclaw doubled-bar issue, `[Pi]` = pi-dsml grammar, `[CS]` = Cherry Studio.
| # | Variant | Shape | Handle by | Test |
|---|---------|-------|-----------|------|
| V1 | Canonical single-bar | `< BAR DSML BAR tool_calls>` complete | parse normally | `v1-canonical` |
| V2 | Doubled full-width bars | `< BAR BAR DSML BAR BAR tool_calls>` | marker accepts 1–2 bars | `v2-doubled-bars` |
| V3 | ASCII pipes | `< | DSML | tool_calls>` | marker accepts `|` | `v3-ascii-pipes` |
| V4 | Space inside marker | `< BAR DSML BAR tool_calls>` / `calls` | trailing `\s*` + `calls` alt | `v4-marker-space` |
| V5 | Truncated block tag | `< BAR DSML BAR calls>` | `calls` alternative for `tool_calls` | `v5-truncated-tag` |
| V6 | Spaces around invoke name | `< BAR DSML BAR invoke name = "x">` | `\s+` around `name=` | `v6-name-spacing` |
| V7 | `tool_call` (singular) | `< BAR DSML BAR tool_call>` | block tag alt | `v7-tool-call-singular` |
| V8 | `function_calls` (V3.2) | `< BAR DSML BAR function_calls>` | block tag alt | `v8-function-calls` |
| V9 | **Missing outer opener** | invoke/parameter appear with no `tool_calls` block | implicit-block rescue | `v9-orphan-invoke` |
| V10 | **Missing invoke opener** | leaked `parameter` only, no `invoke name=` | implicit-invoke rescue | `v10-orphan-parameter` |
| V11 | Mis-spelled closer | `</ BAR DSML BAR >` (bar then `>`), swallowing next param | tolerant scan + drop runaway param | `v11-misspelled-closer` |
| V12 | Runaway invoke name | `invoke name="tool {` (JSON leaked into name) | reject call, strip text | `v12-runaway-name` |
| V13 | Undeclared tool | well-formed, name not in tool set | **keep call**; runtime answers "tool not found" | `v13-undeclared-tool` |
| V14 | Empty invoke name | `name=""` | reject (never trap buffer) | `v14-empty-name` |
| V15 | Unclosed block at end | opener, no closer, stream ends | flush as text, recover complete calls only | `v15-unclosed` |
| V16 | Truncated mid-invoke | invoke has no closing tag | drop partial invoke, keep earlier ones | `v16-truncated-invoke` |
| V17 | Chunk-split opener | `<` `BAR` `DSML` … across deltas | suffix-prefix buffering | `v17-chunk-split` |
| V18 | Text before/after block | prose + block + prose | preserve prose, excise block | `v18-surrounding` |
| V19 | Multiple invokes / blocks | 2+ calls in one or many blocks | parse all | `v19-multiple` |
| V20 | Inside fenced code | grammar shown in ``` fence | **ignore** (no parse, no strip) | `v20-code-fence` |
| V21 | Trailing `‹EOS›` token | `…</…tool_calls>‹EOS›` | strip EOS token | `v21-eos` |
| V22 | HTML entities in values | `&quot;` `&amp;` `&lt;` | decode entities | `v22-entities` |
| V23 | Multi-line / heredoc value | value contains newlines, indentation | preserve verbatim except hugging newline | `v23-multiline-value` |
| V24 | JSON params | `string="false"` | `JSON.parse`, fall back to raw on failure | `v24-json-param` |
| V25 | Raw JSON body (no params) | invoke body is a bare `{...}` | fallback JSON parse | `v25-raw-json-body` |
| V26 | **Wrapped tool** (dominant real shape) | orphan `<parameter name="X">` (marker optional) containing ≥1 complete inner `<MARKER parameter>` | recover call X with inner args (V13 policy: runtime validates) | `v26-*` |
## 3. Reject arms (never emit a call)
The parser is a **strict acceptor**. When in doubt it does not execute; it either strips
noise or leaves text alone. This is deliberate: a false positive executes an arbitrary
tool the model never asked for.
| Reject arm | Rationale | Test |
|-----------|-----------|------|
| No DSML marker anywhere | fast path, zero cost | `reject-no-marker` |
| Text inside a code fence | docs/examples must not execute | `v20-code-fence` |
| Complete call but empty name | unusable, and must not trap buffer | `v14-empty-name` |
| Invoke with no parseable parameter and no JSON body | nothing to run | `reject-empty-args` |
| Native tool calls already present on the message | strip text only, do not double-run | `reject-native-present` |
| `looksLikeDsml` false (no `DSML`/`end-of-sentence`) | skip whole pipeline | `reject-not-dsml` |
## 4. Transition: removal in old transcripts vs. rejection in new streams
The user wants the same engine to serve **two consumers**:
1. **Streaming (new turns, `ctx.aisdk.language`)** — convert a DSML block into a real
`tool-call` part *before* OpenCode's lowering, and rewrite `finishReason` to
`tool-calls`. The block must never reach the transcript.
2. **Stored transcript repair (old sessions)** — walk existing messages, excise DSML
spans, and (for messages that never got a native call) synthesize the missing call.
Both consume the **same `parseDsml(text) -> { calls, ranges }`** function. The only
difference is the sink:
- Stream sink: buffer `text-delta`, emit `tool-input-*` + `tool-call` for each call,
drop `ranges` from the emitted text, set `finish` to `tool-calls` if any call.
- Repair sink: `stripRangesWithNote` for display (short inline note naming the
removed tool when known, e.g. `[removed malformed edit call]`), append calls if
none were native.
Because both share the parser, a variant is "supported" only when both sinks agree, and
the test table covers both.
## 5. Streaming buffer policy
- Hold text ONLY from the first byte that could open a call: a DSML opener, a
partial-opener suffix (`<`, `<BAR`, `<parameter name="sh`, …), or a wrapped-tool
candidate (`<parameter name="X">` with or without marker). Everything before it is
emitted immediately, every chunk — plain prose is never buffered.
- Eager stop: a held candidate that produces no complete parameter-open within
1024 chars is prose — flush as text and resume scanning fresh. Once a complete
parameter is open (inside a value), only the 64 KiB cap bounds the hold.
- Transitions: any non-text part (reasoning, native tool parts) flushes held text
FIRST, preserving transcript order.
- End-of-text guarantee: `text-end` / `finish` / stream flush always resolve the
buffer — recovered calls plus verbatim remainder — so the buffer always ends
empty and no text is ever lost.
- Trailing markup noise (`</invoke>` / `</calls>` arriving after an already
recovered call) is dropped, not leaked.
+207
View File
@@ -0,0 +1,207 @@
# PROJECT STATUS / HANDOFF
> Read this first when resuming. It captures the goal, the design, every decision,
> current test state, and the exact next steps. Companion docs: `docs/PATTERNS.md`
> (variant catalogue) and `docs/TEST-MATRIX.md` (test ↔ behaviour map).
## 1. Goal
Build an **OpenCode V2 plugin** that fixes DeepSeek "DSML" tool calls that leak into
assistant **text** instead of being parsed as structured tool calls. Two consumers:
fix **new streams** and repair **old stored transcripts**.
## 2. Hard facts established (do not re-investigate)
- OpenCode has **no DSML handling** anywhere. Upgrading will not fix it.
`grep -ri dsml` over the whole `v2` source = 0 hits. Installed binary = 0 hits.
- Installed OpenCode: **v2.0.18**. Latest stable `latest` = **2.0.22**. Nightly `dev`
tag = `0.0.0-dev-*`. We target **2.0.22**.
- V2 source repo = `anomalyco/opencode`, **default branch `v2`** (HEAD 2026-10-04).
Worktree: `/code/opencode-v2`. (The older `/code/opencode-src` is `sst/opencode`,
branch `dev`, which is V1 — not what we build against.)
- Failing path in the user's real session: provider **`opencode-go`**, model
**`deepseek-v4.1-flash`**, package **`@ai-sdk/openai-compatible`**, baseURL
`https://opencode.ai/zen/go/v1`. The `opencode` (Zen) provider uses the same shape.
- Trigger (evidence from a real long-context session: an Android speech-to-text
project on `opencode-go/deepseek-v4.1-flash`): **all leaks happen late in a huge
context** (seq 5177+ of ~5700 messages). Degradation:
outer `<DSML tool_calls>` opener missing, `invoke` opener mangled to a bare
`parameter name="edit"`, and only inner params carry the DSML token. `finish_reason`
was `stop`. Matches vLLM #48931 / #54686.
- Two extra hypotheses confirmed: (a) the provider backend's **greedy text→tool-call
extractor** can turn arbitrary prose containing a tool-call-shaped tag into a real
call (we saw this with the user's own messages); (b) the extractor is the same layer
that fails to parse legit DSML at long context.
## 3. Where to insert the middleware (verified against `/code/opencode-v2`)
Chain for the AI-SDK provider path:
```
@ai-sdk/openai-compatible SSE
→ LanguageModelV3 doStream()
→ LanguageModelV3StreamPart stream
→ [ctx.aisdk.hook("language") + wrapLanguageModel] ← OUR MIDDLEWARE
→ streamPartEvents() packages/core/src/aisdk.ts ← OpenCode lowering
→ LLMEvent.toolCall / stepFinish
→ runner/step.ts dispatches tool-call events
```
- Enter at **`ctx.aisdk.hook("language", ...)`** (PluginContext field `aisdk`, type
`AISDKDomain` with `hook: ModelHooks<AISDKHooks>`). Replace `event.language` with
`wrapLanguageModel({ model: event.language, middleware })`.
- We must return **AI-SDK `LanguageModelV3StreamPart`s**. OpenCode already converts
`tool-input-start|delta|end` and `tool-call` into `LLMEvent.toolCall`, then
`runner/step.ts:117` dispatches it. `finish.finishReason.unified` must become
`"tool-calls"` (else runner sees `stop`/`unknown`).
- Other usable hooks if needed: `ctx.session.hook("http.response"|"http.request"|
"context"|"retry"|"prompt")`, `ctx.event.subscribe`, `ctx.session.synthetic/prompt`,
`ctx.storage`. Plugin package `@opencode/plugin` (v2.0.22), `Plugin.define({ id, setup })`.
## 4. Prior art (nothing to duplicate)
- **CherryStudio** (peer Electron app, NOT OpenCode): `deepseekDsmlParserPlugin.ts`,
PR #14747. Streaming state machine + `wrapGenerate`. We port its algorithm.
- **openclaw** (peer agent): `packages/ai/src/transports/openai-completions-dsml.ts`
+ `deepseek-dsml-grammar.ts`. Doubled-bar markers, buffer cap, chunk boundary.
- **pi-dsml** (peer agent, npm `pi-dsml@0.0.1`, unpacked at
`/tmp/opencode/package`): grammar with mangled ` calls>` variant, ranges model,
code-fence guard, `string=false` JSON rule. We port its `parse.ts` shape.
- **vLLM #54686**: leak taxonomy (runaway name / mis-closed param / misspelled opener).
- **No OpenCode plugin exists** for this. Do not go looking again.
## 5. Repository layout (`/code/opencode-dsml-plugin`)
```
docs/PATTERNS.md variant catalogue + reject arms + two-consumer model (source of truth)
docs/TEST-MATRIX.md each behaviour ↔ test id
docs/STATUS.md this file (handoff — read first after compaction)
src/grammar.ts all regexes, each linked to a V# in PATTERNS.md
src/parse.ts parseDsml(text)->{calls,ranges}, stripRanges, orphan rescue
src/middleware.ts createDsmlStreamMiddleware() AI-SDK LanguageModelV3Middleware
src/index.ts (TODO) Plugin.define: aisdk.language hook + fallback
src/fallback.ts (TODO) Strategy A: event subscribe + synthetic resume + storage cap
src/prompt.ts (TODO) correction directive text (see §8)
test/fixtures.ts runtime fixture builders (never store a live DSML token)
test/parse.test.ts 28 parser tests — ALL PASS
test/stream.test.ts 11 streaming tests — ALL PASS
test/wrapper.test.ts generate + wrapper delegation — ALL PASS
test/config.test.ts config matrix, strict parsing, prompt guards, fallback, system nudge — ALL PASS
```
Total: **89 tests, 0 fail**. `tsc --noEmit` clean.
Golden verification against real captured failures (9 wrapped edit/shell leaks
from a long-context DeepSeek session, preserved as synthetic byte-faithful
fixtures in `test/golden/`):
all 9 failing messages now recover the intended call — 5× edit
(newString+oldString+path), 4× shell (command) — with the finish flipping to
`tool-calls` and no text lost. The dominant shape is V26 (wrapped tool); the fix
for the earlier 0-for-9 miss was two parser bugs found by the golden test itself:
orphan ranges shadowing the wrapped rescue (`insideBlock` now checks block ranges
only), and the markerless outer opener needing candidate-hold in the stream.
## 6. Current test state
`bun test` → **39/39 pass** (`test/parse.test.ts` 28/28, `test/stream.test.ts` 11/11).
`./node_modules/.bin/tsc --noEmit` → clean.
Fixed during this session: the streaming `drain` was flushing partial openers
(`<`, `<BAR`, `<BAR BAR DS`) as text instead of holding them — fixed with
`partialOpenerSuffix` hold-back (V17). Streaming `emitResolved` no longer strips
when zero calls were recovered — unclosed/unparseable tails emit verbatim.
## 7. Design decisions (locked)
- Parser is a **strict acceptor**: on doubt, strip noise or leave text; never execute a
guessed tool. `v13-undeclared-tool` is intentionally kept (runtime answers
"tool not found", a better feedback loop).
- `v10` orphan parameter → **strip only, no call** (no tool name). Do not emit `unknown`.
- `v11` mis-closed param → drop the runaway argument, keep the neighbour (vLLM rule).
- `v12` runaway invoke name (`name="tool {`) → no call, strip.
- Code fences are never parsed or stripped.
- Message that already has native tool calls → strip leaked text only, do not re-run.
- Strategy A test-only for now: on idle, if last assistant text ends in DSML, send a
synthetic correction nudge, capped via `ctx.storage`.
## 8. The correction prompt (user's latest requirement)
When the parser **cannot** recover a call, and as preventive guidance, inject a
directive telling the model to always emit tool calls in the correct format **with the
outer XML layer**. Text to be finalised in `src/prompt.ts`, shape:
- Preventive: added via `ctx.session.hook("context")` or a system instruction, telling
the model the exact expected envelope (canonical single-bar, complete outer block,
one `<invoke>` per call, `<parameter>` with `string="true"` for raw strings).
- Recovery-failure nudge: on a stream where DSML appeared but no call was recovered, or
on session idle with leaked markup, send a short synthetic message: "Your last tool
call was malformed (missing the outer block). Re-issue it using the correct format,
wrapped in the outer `<DSML tool_calls>` layer, exactly once."
Exact wording + whether preventive injection is always-on or only for the DEESEEK
providers is **not yet decided** — ask the user.
## 9. Exact next steps
1. ~~Fix the 2 streaming tests~~ — done, 69/69 green.
2. ~~Write `src/index.ts`, `src/prompt.ts`, `src/fallback.ts`~~ — done.
3. ~~System-channel nudge + full strategy config matrix~~ — done (see §11).
4. Live-wire verification: restart the OpenCode service so the plugin entry in
the global `opencode.jsonc` loads, then run a DeepSeek session and
confirm `[dsml]` debug lines + recovery behaviour.
5. Update `docs/PATTERNS.md`/`TEST-MATRIX.md` as rows move from FAIL to PASS.
## 11. Strategy report (final)
Per-request (in `context` hook, before the model call):
- **Preventive directive** (`strategies.directive`, default on): appends a fixed
~130-token instruction to `event.system` stating the exact required envelope
(complete outer block layer, single bars, one invoke per call, parameters inside
invokes). Static bytes every request → one-time prefix shift, then cache-stable.
- **History sanitization** (`strategies.sanitize`, default on): strips leaked DSML
spans from replayed assistant text. Fires only when a leak exists; deterministic
given stored messages, so retries converge to identical bytes (cache-friendly).
Never synthesizes calls (no double-execution).
- **Conditional system nudge** (`strategies.systemNudge`, default on): when the
assembled history ends in *unrecoverable* DSML, pushes the correction as a
**transient system instruction** — never persisted, auto-clears when the
condition clears. Capped per message (id, else content hash) via storage.
Per-response (in `aisdk.language` wrapper, after the provider stream):
- **B1 streaming** (`strategies.stream`): buffers `text-delta` only, holds partial
openers, resolves closed blocks immediately, emits `tool-input-*` + `tool-call`,
flips `finish` to `tool-calls`. Zero calls → buffer emitted verbatim, finish
untouched. 64 KiB cap → over-cap unclosed tail emitted as text. Zero request
impact → zero cache impact.
- **B2 generate** (`strategies.generate`): same recovery for non-streaming results.
Idle safety net (`strategies.watchdog` + `resume.channel`/`maxAttempts`):
- On session idle with unrecovered DSML last message: `channel:"system"` (default)
sends a minimal synthetic wake ping (`"Resume the interrupted tool call."`) and
the context hook attaches the full correction in system on the woken turn;
`channel:"user"` restores the legacy full-text synthetic turn.
- Attempts capped per message; counters in plugin storage.
Fixed prompt texts live in `src/prompt.ts` (`preventiveDirective()`,
`recoveryNudge()`); tests assert they contain no live marker bytes.
Caching verdict: only stable, deterministic bytes are ever added; nothing in the
plugin churns per-request content. The one real invalidation is the one-time shift
when the directive/nudge first appears — expected and unavoidable for any fix, and
documented as acceptable.
Rejected alternative: `session.instructions.entry.put` as the nudge channel. It
renders into the system baseline (ideal placement), but the Promise plugin runtime
allowlists session methods and `instructions` is not exposed (type *and* runtime).
Revisit only if OpenCode exposes it to plugins.
## 10. Environment
- Bun 1.4.2. `bun test`, `./node_modules/.bin/tsc --noEmit` (tsgo unavailable here).
- Deps installed: `@ai-sdk/provider@3.0.18`, `@opencode/plugin@2.0.22`, `typescript`.
- Never write a literal DSML token into a source/doc file — the client/provider may
parse it as a phantom tool call. Always build it via `test/fixtures.ts` or use the
`‹DSML›` placeholder.
+112
View File
@@ -0,0 +1,112 @@
# Test Matrix
One row per parser behaviour. Each row names a fixture (inline string or file under
`test/fixtures/`) and the assertion. Tests are numbered `V#`/`reject-*` to match
`docs/PATTERNS.md`.
Fixture convention: a **fixture is raw text built at runtime** so we can inject the real
`U+FF5C` bytes without this repository ever containing them literally; helpers in
`test/fixtures.ts` provide `m` (marker), `invoke`, `param`, `block`.
## Parser: `parseDsml(text)`
| ID | Fixture | Expect |
|----|---------|--------|
| `v1-canonical` | `block([invoke("bash", [param("command","ls -la")])])` | 1 call `{name:"bash", args:{command:"ls -la"}}`, 1 range covering block |
| `v2-doubled-bars` | same with doubled bars `m2` | 1 call |
| `v3-ascii-pipes` | same with `|DSML|` | 1 call |
| `v4-marker-space` | `< m " calls>"` (space before `calls`) | 1 call |
| `v5-truncated-tag` | block tag `calls` instead of `tool_calls` | 1 call |
| `v6-name-spacing` | `invoke name = "bash"` | 1 call |
| `v7-tool-call-singular` | block tag `tool_call` | 1 call |
| `v8-function-calls` | block tag `function_calls` | 1 call |
| `v9-orphan-invoke` | `invoke("edit",[param("path","a.kt")])` with **no** block | 1 call, range covers invoke |
| `v10-orphan-parameter` | `param("content","x")` only (no invoke) | 1 call named `unknown`? **NO** — see note |
| `v11-misspelled-closer` | `param("alpha",..)‹BAR›>‹BAR›parameter name="beta"..` | 2 calls: alpha + beta (not swallowed) |
| `v12-runaway-name` | `invoke("record_item {\\ncategory")` | 0 calls, range strips it |
| `v13-undeclared-tool` | `invoke("totally_made_up",[param("x","y")])` | 1 call kept |
| `v14-empty-name` | `invoke("",[param("x","y")])` | 0 calls, no infinite loop |
| `v15-unclosed` | `block-open + invoke("bash",..)` with no closes | calls from complete invokes only; remainder as text |
| `v16-truncated-invoke` | two invokes, 2nd missing `</invoke>` | 1 call (first) |
| `v18-surrounding` | `"before " + block + " after"` | text before/after preserved, block ranged |
| `v19-multiple` | two invokes in one block + two blocks | 4 calls |
| `v20-code-fence` | `"```\n" + block + "\n```"` | 0 calls, 0 ranges |
| `v21-eos` | `block + eosMarker` | eos in ranges |
| `v22-entities` | param value `a &amp; b &quot;q&quot;` | decoded `a & b "q"` |
| `v23-multiline-value` | `param` value with `\n` and indent | value preserved, only hug newlines removed |
| `v24-json-param` | `param` `string="false"` value `{"a":1}` | args[{a:1}] as object |
| `v25-raw-json-body` | invoke body `{"a":1}` no params | args `{a:1}` |
| `v26-wrapped-tool` etc. | markerless/markered outer + complete inners | call X recovered; plain prose / incomplete nesting untouched |
| `reject-no-marker` | `"hello world"` | 0 calls, 0 ranges |
| `reject-not-dsml` | long prose, no marker | fast path (looksLikeDsml false) |
| `reject-empty-args` | `invoke("bash",[])` | 0 calls (nothing to run) |
**Note on `v10-orphan-parameter`:** a bare `parameter` with no enclosing invoke has no
tool name, so it cannot become a call. Decision: **strip the noise, emit no call**
(not `unknown`). Executing an `unknown` tool is worse than dropping an unusable
fragment. This row therefore expects `calls: []`, `ranges: [the parameter markup]`.
## Streaming: `createDsmlStreamMiddleware()`
| ID | Fixture | Expect |
|----|---------|--------|
| `stream-text-passthrough` | plain text deltas | all `text-delta` emitted, no tool parts |
| `stream-v2-recovered` | doubled-bar block split per token | `tool-input-start/delta/end` + `tool-call`, no leaked text |
| `stream-v17-chunk-split` | opener split `<`/`BAR`/`DSML`/… | recovered, no partial marker leaked |
| `stream-finish-rewrite` | recovered call, source finish `stop` | finish `tool-calls` |
| `stream-finish-untouched` | no DSML | finish stays `stop` |
| `stream-unclosed-fallback` | open block, then `text-end` | buffered text emitted verbatim, finish `stop` |
| `stream-empty-invoke-fallback` | closed block, unparseable | block re-emitted as text, finish `stop` |
| `stream-surrounding` | `"a " + block + " b"` | text `"a b"`, 1 call |
| `stream-multiline-edit` | real captured edit shape (DB V9) | 1 `edit` call with correct args |
| `stream-orphan-invoke` | V9 degradation mid-stream | 1 call |
| `stream-buffer-cap` | >64 KiB unclosed | fallback to text, no unbounded growth |
| `stream-eager-stop` | prose opener, no structure | flushed early as text, nothing held |
| `stream-transition` | reasoning part mid-text | held text flushed first, order preserved |
## Repair: `recoverStoredMessage(message)`
| ID | Fixture | Expect |
|----|---------|--------|
| `repair-strip-only` | assistant msg with native tool-call + leaked text | text stripped, calls **not** appended |
| `repair-append` | assistant msg with leaked call, no native | text stripped, call appended, `stopReason:"toolUse"` |
| `repair-thinking` | leaked block in a `thinking` part | thinking stripped |
| `repair-idempotent` | run twice | second run is a no-op |
> Status: the repair *parser* (`parseDsml` + `stripRanges`) is fully tested. The
> message-level append wrapper is intentionally **not** built: OpenCode persists
> assistant messages durably and the plugin has no safe write path for them, so
> repair happens at request time via history sanitization in the `context` hook
> (`src/index.ts`) instead of mutating stored messages.
## Config / modes
| ID | Fixture | Expect |
|----|---------|--------|
| `config-defaults` | `resolveConfig()` | both Zen providers, relaxed, resume 3 |
| `config-strict` | `mode:"strict"` | every rescue off |
| `config-granular` | one rescue off | others stay on |
| `strict-block` | complete block + strict flags | parses |
| `strict-orphan` | orphan invoke + strict flags | no call |
| `prompt-no-marker` | both directives | no live marker bytes, outer-layer rule stated |
| `fallback-*` | decide/messageText/attempts | nudge policy + cap |
## Wrapper: `wrapDsmlLanguageModel`
| ID | Fixture | Expect |
|----|---------|--------|
| `wrapper-stream` | fake model, block in stream | 1 tool-call, finish `tool-calls` |
| `wrapper-passthrough` | plain text | untouched |
| `wrapper-generate` | block in generate text | tool-call content part, finish `tool-calls` |
## Sources / provenance
Fixtures marked with a source are byte-faithful captures from that source; others are
constructed from the grammar. Provenance is recorded in `test/fixtures.ts` beside each.
- `[DB]` shapes observed in a real long-context DeepSeek session: wrapped
edit/shell leaks with O(1000)-char multiline values and shell metachars
- `[vLLM]` vLLM #54686 leak classes (runaway name, mis-closed param, misspelled opener)
- `[OpenClaw]` openclaw #128858 doubled full-width bars
- `[Pi]` pi-dsml grammar (mangled ` calls>` variant)
- `[CS]` CherryStudio #14747 chunk-split SSE sample