mirror of
https://github.com/bendtherules/opencode-plugin-dsml-fix.git
synced 2026-10-17 16:56:50 +00:00
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:
@@ -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 | `"` `&` `<` | 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
@@ -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.
|
||||
@@ -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 & b "q"` | 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
|
||||
Reference in New Issue
Block a user