From eb5fb39b52bf31b834a73f4f3d42c8e130297eb0 Mon Sep 17 00:00:00 2001 From: bendtherules Date: Mon, 5 Oct 2026 19:37:34 +0530 Subject: [PATCH] feat: simplified config, send-once retry, README rewrite Breaking config (pre-adoption, no aliases kept): parse/history/responseFix/ retry namespaces, top-level providers+debug only. Removed: mode presets, preventive directive, per-transport flags, maxAttempts (send-once per message instead). README rewritten usage-first with learnings, per-section defaults, research links. --- README.md | 272 +++++++++++++++++++++++++++++--------------- docs/STATUS.md | 43 +++---- docs/TEST-MATRIX.md | 14 +-- package.json | 2 +- src/config.ts | 175 +++++++++------------------- src/fallback.ts | 39 +++---- src/index.ts | 72 ++++++------ src/parse.ts | 3 +- src/prompt.ts | 37 ++---- test/config.test.ts | 153 ++++++++++--------------- 10 files changed, 386 insertions(+), 424 deletions(-) diff --git a/README.md b/README.md index 4a3526f..4fe7c53 100644 --- a/README.md +++ b/README.md @@ -1,125 +1,208 @@ # opencode-plugin-dsml-fix -OpenCode V2 plugin that recovers DeepSeek DSML tool calls when the provider's -parser fails and raw markup leaks into assistant text as prose. +Recovers DeepSeek DSML tool calls that leak into assistant text. OpenCode V2 plugin. ## The problem -On DeepSeek served through OpenCode (notably `opencode-go/deepseek-*`), tool -calls sometimes arrive as visible XML-ish markup in the assistant message -instead of executing — the agent stalls or pastes the markup back at you. It -happens most in long sessions, where the provider's parser degrades: the outer -block opener goes missing, invoke openers get mangled, and only the inner -parameter tags survive. +DeepSeek sometimes sends a tool call as visible markup instead of executing +it. The agent stalls mid-task, and you end up looking at raw tags in the chat. -This plugin repairs those leaks client-side, in OpenCode, without waiting on -the provider. Normal responses pass through byte-identical. +Three things make it worse than a single failed turn: + +- It hits late in long sessions, when the provider's parser degrades: the + outer block opener goes missing, invoke openers get mangled, only inner + parameter tags survive. +- The broken parser stays silent. It passes the wreckage through as ordinary + text instead of raising, so nothing errors. Looks like the model just + declined to call the function. +- The leak lands in the transcript, and from there the model copies its own + broken format. Every turn after fails the same way until you intervene. + +This plugin repairs those leaks inside OpenCode. Good responses pass through +untouched. + +## What I learned + +Read this before touching any setting. Two sources: my own long sessions on +`opencode-go/deepseek-*`, and the upstream vLLM reports. + +1. **History is the real battlefield.** Fresh leaks are rare — the vLLM team + served billions of production tokens, sampled 9,500 responses, and found + 76 leaks ([0.80%](https://github.com/vllm-project/vllm/pull/54686)). But + one leak in the transcript poisons every turn after it, because the model + imitates its own degraded output. vLLM + [#40801](https://github.com/vllm-project/vllm/issues/40801) reports the + same contamination. So live recovery fires rarely by design, and **history + sanitization matters more than everything else combined**. If you change + one flag, make it `history.sanitize`. Leave it on. +2. **It only strikes at long context.** Every leak I saw came thousands of + messages into a session, never early. Short sessions need nothing. The + plugin sits idle and costs nothing. +3. **The backend is inconsistent.** My working theory: `opencode-go` fans out + across multiple backend providers and only some mangle DSML. Identical + prompts fail or succeed at random. I stopped waiting for a provider fix — + there may be no single provider to fix. ## Install -```jsonc -// opencode.jsonc -{ - "$schema": "https://opencode.ai/config.json", - "plugins": ["opencode-plugin-dsml-fix"], -} +```sh +opencode plugin add opencode-plugin-dsml-fix ``` -Requires OpenCode V2 (`2.0.x`). No other setup — defaults cover the -`opencode-go` and `opencode` (Zen) providers. - -To confirm it's loaded, run any prompt and look for the probe log -(`debug: true`, see below). +- Requires OpenCode V2 (`2.0.x`). +- Defaults have everything enabled: `parse`, `history`, `responseFix`, + `retry`. The only off-by-default flag is `debug`. ## What it does -- **Streaming recovery.** Leaked DSML blocks in response text become real tool - calls, so the agent loop dispatches the intended call instead of stalling. - Covers both the native SSE path (what `opencode-go` uses) and the AI-SDK - path (other providers). -- **History sanitization.** Strips leaked markup from replayed assistant - messages so the model stops imitating its own degraded output. History is - never turned into calls — no double execution. -- **Prevention.** A system directive states the exact required envelope, plus - a conditional nudge when a turn ends with unrecovered markup. -- **Safety net.** An idle watchdog sends one short retry when a session stalls - on unrecovered markup, capped per message. - -Every layer is independently toggleable (see Configuration). If any layer -misfires, switch that one flag off — the plugin degrades to a safer subset -and never breaks normal inference. - -## Verify it's working - -Enable debug logging: - -```jsonc -{ - "plugins": [ - { - "package": "opencode-plugin-dsml-fix", - "options": { "debug": true }, - }, - ], -} -``` - -Each turn logs one request line and one line per response attempt, -identified as `sessionID/user-messageID` (`#n` counts retries in the turn): - -``` -[dsml] request ses_abc/msg_xyz sanitized=2msgs/3parts directive=yes nudge=no -[dsml] response ses_abc/msg_xyz#1 recovered=1 -[dsml] response ses_abc/msg_xyz#2 passthrough -``` - -- `sanitized=Nmsgs/Mparts` — replayed history stripped (plus a short note). -- `recovered=N` — tool calls rescued from leaked markup. The fix fired. -- `passthrough` — no markup seen, bytes untouched. -- `held-candidate-no-call` — something looked like markup but parsed to - nothing (emitted verbatim). Frequent sightings mean a new variant worth - reporting — file an issue with the excerpt. +1. **Parse leaked tool calls.** Tolerant parser for complete blocks plus + every degraded shape I catalogued: orphan invokes, mis-closed parameters, + bare-JSON bodies, wrapped tools, marker noise, chunk-split tags. + [Settings](#parse) +2. **Sanitize history.** Strips old leaks from replayed assistant text, with + a short inline note where each one was. History never becomes calls, so + nothing executes twice. [Settings](#history) +3. **Fix live tool calls.** Leaked markup in a live response becomes a real + call, so the agent loop dispatches it instead of stalling. + [Settings](#responsefix) +4. **Retry stalled turns.** Sometimes a reply is too broken to repair — + the markup can't become a call no matter how tolerant the parser is. + Instead of leaving the session stuck, the plugin tells the model its last + reply had broken tool markup and asks it to re-issue the call properly. + If the session then sits idle, it sends one minimal wake-up to get things + moving again. Each broken reply gets one correction and at most one wake. + A reply that stays silent after its poke is left alone — if the first poke + got no reply, the cause is outside anything a second poke can fix. A new + reply always starts fresh, so retrying never stops a live chat. + [Settings](#retry) ## Configuration +Object form in `opencode.jsonc`: + ```jsonc { "plugins": [ { "package": "opencode-plugin-dsml-fix", "options": { - "providers": ["opencode-go", "opencode"], - "mode": "relaxed", - "resume": { "enabled": true, "maxAttempts": 3 }, + "parse": { "wrappedTool": false }, + "retry": { "maxAttempts": 5 }, }, }, ], } ``` +### `parse` + +Default: all on. Controls parser tolerance, for live recovery and for +deciding what counts as a stain in history (same engine, both directions). + +| Option | Default | Meaning | +|---|---|---| +| `orphanInvoke` | `true` | Invokes with no outer block opener. | +| `looseParameters` | `true` | Re-scan past broken closers. | +| `rawJsonBody` | `true` | Bare-JSON invoke bodies. | +| `wrappedTool` | `true` | Orphan named parameter with complete inner params. | + +When to disable one: that shape misfires (recovers something that was never +a call). Example: `"parse": { "wrappedTool": false }`. No master switch on +purpose. Flip individual flags. + +### `history` + +Default: on (`sanitize: true`). Highest impact layer (see above). + +When to disable: don't. Switching it off re-poisons the transcript and the +imitation loop comes back. If the redaction notes bother you, file an issue +instead. + +### `responseFix` + +Default: on (`enabled: true`). One switch for the whole layer. + +| Option | Default | Meaning | +|---|---|---| +| `enabled` | `true` | Turn leaked markup into real tool calls. | +| `bufferLimit` | `65536` | Cap on held text while deciding. Safety bound, not a target. | + +When to disable: recovery ever corrupts a good turn +(`"responseFix": { "enabled": false }`). + +On buffering, since people ask: normal text is never held. The fix holds +only a span that already opened as a real candidate (released if no call +structure develops), or a trailing fragment that already looks like a broken +tag (capped, released if it never becomes one). Everything else streams +through untouched. + +### `retry` + +Default: on. Wakes stalled turns, carries the conditional correction. + +| Option | Default | Meaning | +|---|---|---| +| `enabled` | `true` | Idle wake for turns ending in unrecovered markup. One per message. | +| `channel` | `"system"` | `"system"` = minimal ping, correction rides in system. `"user"` = full correction as a synthetic user turn. | +| `nudge` | `true` | Correction when history ends unrecovered. One per message. | + +Send-once per message: a reply that stays silent after its poke is left +alone. New replies always start fresh, so the cap never stops a live chat — +it only stops re-poking a message that never changes, where each poke would +burn a model call for nothing. + +When to disable: wakes interrupt you (`"retry": { "enabled": false }`), or +keep the wake but drop the correction text (`"retry": { "nudge": false }`). + +### General + | Option | Default | Meaning | |---|---|---| | `providers` | `["opencode-go","opencode"]` | Provider IDs in scope. Empty disables the plugin. | -| `mode` | `"relaxed"` | `"relaxed"` = full variant catalogue; `"strict"` = complete blocks only. | -| `rescue.orphanInvoke` | mode | Invokes with no outer opener. | -| `rescue.looseParameters` | mode | Tolerant re-scan past broken closers. | -| `rescue.rawJsonBody` | mode | Bare-JSON invoke bodies. | -| `rescue.wrappedTool` | mode | Orphan `` with complete inner params. | -| `bufferLimit` | `65536` | Streaming hold cap in bytes. | -| `response.sse` | `true` | Native path: rewrite SSE bytes (`http.response`). | -| `response.aisdkStream` | `true` | AI-SDK path: streaming parts. | -| `response.aisdkGenerate` | `true` | AI-SDK path: non-streaming results. | -| `request.sanitize` | `true` | Strip leaked spans from replayed history. | -| `request.directive` | `true` | Preventive system directive. | -| `request.systemNudge` | `true` | Conditional correction when a turn ends unrecovered. | -| `resume.enabled` | `true` | Idle watchdog wake. | -| `resume.maxAttempts` | `3` | Cap per assistant message. | -| `resume.channel` | `"system"` | `"system"` = minimal ping + nudge; `"user"` = legacy full-text turn. | -| `debug` | `false` | Per-turn impact logging (see above). | +| `debug` | `false` | Per-turn logging (see Verify). Only off-by-default flag. | -Older names (`recovery.*`, `strategies.*`, `resume.*`, `prompt.*`) still work -as aliases. Precedence: `response`/`request` > `recovery` > `strategies` > -legacy names > defaults. +Full shape catalogue: `docs/PATTERNS.md`. + +## Verify + +Set `"debug": true`. Each turn logs one request line plus one line per +response attempt (`sessionID/user-messageID`, `#n` counts retries): + +``` +[dsml] request ses_abc/msg_xyz sanitized=2msgs/3parts nudge=no +[dsml] response ses_abc/msg_xyz#1 recovered=1 +[dsml] response ses_abc/msg_xyz#2 passthrough +``` + +- `sanitized=Nmsgs/Mparts`: replayed history stripped, notes left behind. +- `recovered=N`: the fix fired. +- `passthrough`: no markup seen, bytes untouched. +- `held-candidate-no-call`: markup-shaped but unparseable, emitted verbatim. + Seeing this often means a new variant. File an issue with the excerpt. + +## Research + +Same bug class, convergent fixes. This is the OpenCode-native one: + +- [vLLM #54686](https://github.com/vllm-project/vllm/pull/54686): leak + taxonomy with production shares (mis-closed param 49%, runaway invoke + name 32%, unrecognized opener 21%). My catalogue mirrors it. +- [vLLM #48931](https://github.com/vllm-project/vllm/issues/48931): START + token omitted at long context (~95k+ tokens); same orphan-invoke fallback + I ship. Fixed upstream by + [#55954](https://github.com/vllm-project/vllm/pull/55954). +- [vLLM #40801](https://github.com/vllm-project/vllm/issues/40801): + streaming leaks plus the history-contamination effect behind my + sanitize-first stance. +- [CherryStudio #14747](https://github.com/CherryHQ/cherry-studio/pull/14747): + streaming DSML state machine and generate wrapper. I ported the algorithm + into the middleware. +- [openclaw](https://github.com/openclaw/openclaw) DSML transport and + grammar: doubled-bar markers, buffer cap, chunk boundaries. Same + conclusions. +- [pi-mono `pi-dsml`](https://github.com/badlogic/pi-mono) grammar: mangled + closers, ranges model, code-fence guard, `string=false` JSON rule. Shapes + ported into my parser. ## Development @@ -127,13 +210,14 @@ legacy names > defaults. bun install bun test # 115 tests: grammar, parser, stream, wrapper, config, sse, golden bun run check # typecheck + tests +bun run build # compiled dist/ for npm (prepublish runs check + build) ``` -- `docs/PATTERNS.md` — failure-shape catalogue (source of truth for recovery) -- `docs/TEST-MATRIX.md` — behaviour ↔ test map -- `test/golden/` — synthetic byte-faithful fixtures + generator - (`build-fixtures.ts`); the repo never stores live markup tokens +- `docs/PATTERNS.md`: failure-shape catalogue, source of truth for recovery +- `docs/TEST-MATRIX.md`: behaviour to test map +- `test/golden/`: synthetic fixtures and generator (`build-fixtures.ts`). + No live markup bytes anywhere in the repo. ## License -MIT — see [LICENSE](./LICENSE). +MIT. See [LICENSE](./LICENSE). diff --git a/docs/STATUS.md b/docs/STATUS.md index 8840f21..fb65e1c 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -121,10 +121,14 @@ when zero calls were recovered — unclosed/unparseable tails emit verbatim. - `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`. +- Strategy A live: on idle, if last assistant text ends in DSML, send a + synthetic correction nudge, send-once per message via `ctx.storage`. -## 8. The correction prompt (user's latest requirement) +## 8. The correction prompt (decided: nudge only, no preventive directive) + +> Outcome: the preventive directive was removed — only the recovery-failure +> nudge shipped (`recoveryNudge()` in `src/prompt.ts`, wired as `retry.nudge` +> + idle wake). Original requirement below for the record. 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 @@ -151,46 +155,43 @@ providers is **not yet decided** — ask the user. 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) +## 11. Feature 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 +- **History sanitization** (`history.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 +- **Conditional nudge** (`retry.nudge`, 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. + condition clears. Send-once per message (id, else content hash) via storage. -Per-response (in `aisdk.language` wrapper, after the provider stream): +Per-response (live fix, `responseFix.enabled`, default on): -- **B1 streaming** (`strategies.stream`): buffers `text-delta` only, holds partial +- **AI-SDK path**: 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. + impact → zero cache impact. Same recovery for non-streaming results. +- **Native SSE path** (`http.response` rewrite, the one `opencode-go` uses): + same parser, same policy on raw SSE bytes. -Idle safety net (`strategies.watchdog` + `resume.channel`/`maxAttempts`): +Retry safety net (`retry.enabled` + `retry.channel`, send-once per message): - 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. + `channel:"user"` sends the full correction as a synthetic user turn. +- One poke per message; a message that stays silent is left alone. -Fixed prompt texts live in `src/prompt.ts` (`preventiveDirective()`, -`recoveryNudge()`); tests assert they contain no live marker bytes. +The fixed nudge text lives in `src/prompt.ts` (`recoveryNudge()`); tests assert +it contains 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 +when the nudge first appears — expected and unavoidable for any fix, and documented as acceptable. Rejected alternative: `session.instructions.entry.put` as the nudge channel. It diff --git a/docs/TEST-MATRIX.md b/docs/TEST-MATRIX.md index 90f92b2..96b367d 100644 --- a/docs/TEST-MATRIX.md +++ b/docs/TEST-MATRIX.md @@ -83,13 +83,13 @@ fragment. This row therefore expects `calls: []`, `ranges: [the parameter markup | 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 | +| `config-defaults` | `resolveConfig()` | both Zen providers, all flags on, debug off | +| `config-granular` | one parse flag off | others stay on | +| `config-layers` | each layer off | that layer disabled | +| `parse-flags-off` | complete block + all flags off | parses | +| `parse-orphan-off` | orphan invoke + flags off | no call | +| `prompt-no-marker` | recovery nudge | no live marker bytes, outer-layer rule stated | +| `fallback-*` | decide/messageText/send-once | nudge policy + send-once | ## Wrapper: `wrapDsmlLanguageModel` diff --git a/package.json b/package.json index 03d6d26..a47d195 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "opencode-plugin-dsml-fix", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "license": "MIT", "description": "OpenCode V2 plugin that recovers DeepSeek DSML tool calls leaked as assistant text", diff --git a/src/config.ts b/src/config.ts index bb0e25d..5731cad 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,8 +1,11 @@ /** * Plugin configuration. Every behaviour the plugin adds can be narrowed back to a - * simpler version without uninstalling: if the deeper fix misbehaves, turn its flag - * off and the plugin degrades to the safer subset. The invariant is that the plugin + * simpler version without uninstalling: if one shape misfires, switch its flag off + * and the plugin degrades to a safer subset. The invariant is that the plugin * must never break normal inference — when in doubt it passes traffic through. + * + * Namespaces match the four features in the README: `parse`, `history`, + * `responseFix`, `retry`. Top level holds only general settings. */ export interface DsmlPluginOptions { @@ -12,159 +15,91 @@ export interface DsmlPluginOptions { */ providers?: string[] /** - * Parsing strictness. - * - `"relaxed"` (default): full catalogue — complete blocks, doubled bars, - * truncated tags, orphan-invoke rescue, mis-closed fallback, raw-JSON body. - * - `"strict"`: complete `tool_calls`/`function_calls` blocks with proper closers - * only. Use when the deeper rescue ever misfires. + * Parser tolerance. Each flag defaults to true; switch one off in isolation + * when that shape misfires. The same flags govern live recovery and what + * counts as a stain in history (one engine, both directions). */ - mode?: "strict" | "relaxed" - /** - * Granular rescue toggles. Each defaults to the `mode` value; setting one - * explicitly overrides the mode for that area so a single failing rescue can be - * switched off in isolation. - */ - rescue?: { - /** Recover `invoke` blocks with no outer opener (V9). Default: mode. */ + parse?: { + /** Recover `invoke` blocks with no outer opener. Default true. */ orphanInvoke?: boolean - /** Tolerant re-scan when a value ran past a broken closer (V11). Default: mode. */ + /** Tolerant re-scan when a value ran past a broken closer. Default true. */ looseParameters?: boolean - /** Bare-`{…}` invoke body parsed as JSON (V25). Default: mode. */ + /** Bare-`{…}` invoke body parsed as JSON. Default true. */ rawJsonBody?: boolean - /** Wrapped tool: orphan `` with complete inner params (V26). Default: mode. */ + /** Orphan `` with complete inner params. Default true. */ wrappedTool?: boolean } - /** Streaming swallow cap in bytes (V15/buffer-cap). Default 65536. */ - bufferLimit?: number - /** - * Response-side recovery: turn leaked markup into real tool calls. - * The two transports are independent paths to the model and are named as such. - * (`recovery` is accepted as a deprecated alias; `response` wins when set.) - */ - response?: { - /** Native path: rewrite SSE bytes in `http.response` (opencode-go). Default true. */ - sse?: boolean - /** AI-SDK path: wrap the language model, streaming parts. Default true. */ - aisdkStream?: boolean - /** AI-SDK path: wrap the language model, non-streaming results. Default true. */ - aisdkGenerate?: boolean - } - /** - * Deprecated alias for `response`. When both forms are set, `response` wins. - */ - recovery?: { - /** Alias: response.sse. */ - sse?: boolean - /** Alias: response.aisdkStream. */ - aisdkStream?: boolean - /** Alias: response.aisdkGenerate. */ - aisdkGenerate?: boolean - } - /** - * Request-side shaping: control what the model sees in history and system. - */ - request?: { - /** Strip leaked DSML spans from replayed assistant history. Default true. */ + /** History sanitization: strip leaked spans from replayed assistant text. */ + history?: { + /** Default true. Leave on: disabling re-poisons the transcript. */ sanitize?: boolean - /** Preventive system directive on every request. Default true. */ - directive?: boolean - /** Conditional correction nudge in system when history ends in unrecovered DSML. Default true. */ - systemNudge?: boolean } - /** - * Per-strategy switches (deprecated alias for `recovery` + `request`; kept for - * backward compatibility). When both forms are set, `recovery`/`request` win. - */ - strategies?: { - /** Alias: recovery.aisdkStream. */ - stream?: boolean - /** Alias: recovery.aisdkGenerate. */ - generate?: boolean - /** Alias: recovery.sse. */ - sse?: boolean - /** Alias: request.sanitize. */ - sanitize?: boolean - /** Alias: request.directive. */ - directive?: boolean - /** Alias: request.systemNudge. */ - systemNudge?: boolean - /** Alias: watchdog.enabled. */ - watchdog?: boolean + /** Live response fix: turn leaked markup into real tool calls. */ + responseFix?: { + /** Master switch for all transports. Default true. */ + enabled?: boolean + /** Streaming hold cap in bytes. Safety bound, not a target. Default 65536. */ + bufferLimit?: number } - /** Strategy-A auto-resume safety net (idle watchdog). */ - resume?: { + /** Retry safety net for turns the fix could not recover. */ + retry?: { /** - * Master switch (aliases: strategies.watchdog). Default true. - * Precedence: strategies.watchdog > resume.enabled > default. + * Master switch for the idle wake. Default true. + * Send-once per message: a message that stays silent after one + * wake is left alone, never re-poked. */ enabled?: boolean - /** Max nudges per assistant message. Default 3. */ - maxAttempts?: number /** - * Delivery channel for the watchdog wake. - * - `"system"` (default): the wake is a minimal synthetic ping; the full - * correction rides as a transient system instruction via the context hook. - * - `"user"`: legacy single-message correction nudge as a synthetic user turn. + * Conditional correction nudge when history ends in unrecovered markup. + * Default true. + */ + nudge?: boolean + /** + * Delivery channel for the wake. + * - `"system"` (default): minimal wake ping; the correction rides as a + * transient system instruction via the context hook. + * - `"user"`: full correction as a synthetic user turn. */ channel?: "system" | "user" } - /** Preventive system instruction (alias: request.directive). Default true. */ - prompt?: { - /** - * Master switch. Default true. - * Precedence: request.directive > strategies.directive > prompt.enabled > default. - */ - enabled?: boolean - } - /** Log recoveries to stderr. Default false. */ + /** Log recoveries to stderr. Default false. The only off-by-default flag. */ debug?: boolean } export interface ResolvedDsmlConfig { readonly providers: readonly string[] - readonly relaxed: boolean readonly orphanInvoke: boolean readonly looseParameters: boolean readonly rawJsonBody: boolean readonly wrappedTool: boolean readonly bufferLimit: number - readonly streamEnabled: boolean - readonly generateEnabled: boolean - readonly sseEnabled: boolean + readonly responseFixEnabled: boolean readonly sanitizeEnabled: boolean - readonly directiveEnabled: boolean - readonly systemNudgeEnabled: boolean - readonly watchdogEnabled: boolean - readonly resumeMaxAttempts: number - readonly resumeChannel: "system" | "user" + readonly retryEnabled: boolean + readonly retryNudgeEnabled: boolean + readonly retryChannel: "system" | "user" readonly debug: boolean } const DEFAULT_PROVIDERS = ["opencode-go", "opencode"] as const export function resolveConfig(input: DsmlPluginOptions = {}): ResolvedDsmlConfig { - const relaxed = (input.mode ?? "relaxed") === "relaxed" - const s = input.strategies ?? {} - const response = input.response ?? {} - const recovery = input.recovery ?? {} - const request = input.request ?? {} + const parse = input.parse ?? {} + const history = input.history ?? {} + const responseFix = input.responseFix ?? {} + const retry = input.retry ?? {} return { providers: input.providers ?? [...DEFAULT_PROVIDERS], - relaxed, - orphanInvoke: input.rescue?.orphanInvoke ?? relaxed, - looseParameters: input.rescue?.looseParameters ?? relaxed, - rawJsonBody: input.rescue?.rawJsonBody ?? relaxed, - wrappedTool: input.rescue?.wrappedTool ?? relaxed, - bufferLimit: input.bufferLimit ?? 64 * 1024, - streamEnabled: response.aisdkStream ?? recovery.aisdkStream ?? s.stream ?? true, - generateEnabled: response.aisdkGenerate ?? recovery.aisdkGenerate ?? s.generate ?? true, - sseEnabled: response.sse ?? recovery.sse ?? s.sse ?? true, - sanitizeEnabled: request.sanitize ?? s.sanitize ?? true, - directiveEnabled: request.directive ?? s.directive ?? input.prompt?.enabled ?? true, - systemNudgeEnabled: request.systemNudge ?? s.systemNudge ?? true, - watchdogEnabled: s.watchdog ?? input.resume?.enabled ?? true, - resumeMaxAttempts: input.resume?.maxAttempts ?? 3, - resumeChannel: input.resume?.channel ?? "system", + orphanInvoke: parse.orphanInvoke ?? true, + looseParameters: parse.looseParameters ?? true, + rawJsonBody: parse.rawJsonBody ?? true, + wrappedTool: parse.wrappedTool ?? true, + bufferLimit: responseFix.bufferLimit ?? 64 * 1024, + responseFixEnabled: responseFix.enabled ?? true, + sanitizeEnabled: history.sanitize ?? true, + retryEnabled: retry.enabled ?? true, + retryNudgeEnabled: retry.nudge ?? true, + retryChannel: retry.channel ?? "system", debug: input.debug ?? false, } } diff --git a/src/fallback.ts b/src/fallback.ts index b94c591..02ff51a 100644 --- a/src/fallback.ts +++ b/src/fallback.ts @@ -1,12 +1,13 @@ /** - * Strategy A: auto-resume safety net for turns the streaming middleware could not - * recover. Pure decision logic lives here so it is unit-testable; OpenCode I/O - * (events, session reads, synthetic prompts, storage) is injected by `src/index.ts`. + * Retry safety net for turns the live fix could not recover. Pure decision logic + * lives here so it is unit-testable; OpenCode I/O (events, session reads, + * synthetic prompts, storage) is injected by `src/index.ts`. * - * Policy: when a session goes idle and its latest assistant text ends in DSML markup + * Policy: when a session goes idle and its latest assistant text holds DSML markup * that never became a tool call, send one short correction nudge (see - * `src/prompt.ts`). Attempts are capped per message so a pathological loop cannot - * run away. + * `src/prompt.ts`). Send-once per message: a message that stays silent after one + * wake is left alone, never re-poked — if the first poke got no reply the cause + * is outside anything a second poke can fix. */ import { looksLikeDsml } from "./grammar.ts" @@ -18,7 +19,7 @@ export interface FallbackDecision { readonly send: boolean /** The nudge text, present when `send` is true. */ readonly text?: string - /** Storage key the attempt was (or would be) counted under. */ + /** Storage key the send was (or would be) recorded under. */ readonly key?: string /** Why no nudge is sent. */ readonly reason?: string @@ -46,13 +47,13 @@ export interface HookMessage { } export interface SystemNudge { - /** Cap key: message id when present, otherwise a hash of the text. */ + /** Dedupe key: message id when present, otherwise a hash of the text. */ readonly key: string /** The correction text for the system channel. */ readonly text: string } -/** Stable cap key for a hook message: id when present, content hash otherwise. */ +/** Stable dedupe key for a hook message: id when present, content hash otherwise. */ export function nudgeCapKey(message: HookMessage): string { if (message.id) return message.id return contentHash(message) @@ -83,7 +84,7 @@ export function turnId(messages: ReadonlyArray): string { /** * Decide whether the assembled request history ends in unrecovered DSML and needs * the correction as a transient system instruction. Pure: the caller enforces the - * per-key cap via storage. Returns undefined when no nudge is warranted. + * send-once rule via storage. Returns undefined when no nudge is warranted. */ export function needsSystemNudge( messages: ReadonlyArray, @@ -100,16 +101,17 @@ export function needsSystemNudge( /** * Decide whether the latest assistant text warrants a correction nudge. + * Send-once per message: `alreadySent` means this message was already poked + * once and stayed silent — leave it alone. * - * @param text the latest assistant text (already stripped of nothing) - * @param attempts nudges already sent for this message - * @param maxAttempts cap from config + * @param text the latest assistant text + * @param alreadySent whether a nudge/wake was already sent for this message */ -export function decideResume(text: string, attempts: number, maxAttempts: number): FallbackDecision { +export function decideResume(text: string, alreadySent: boolean): FallbackDecision { if (!looksLikeDsml(text)) return { send: false, reason: "no-marker" } const { calls } = parseDsml(text) if (calls.length > 0) return { send: false, reason: "already-recoverable" } - if (attempts >= maxAttempts) return { send: false, reason: "cap-reached" } + if (alreadySent) return { send: false, reason: "already-sent" } return { send: true, text: recoveryNudge() } } @@ -124,8 +126,7 @@ export function messageText(message: { return parts.map((part) => part.text as string).join("\n") } -/** Parse an attempt counter value read from storage. */ -export function parseAttempts(value: unknown): number { - if (typeof value === "number" && Number.isFinite(value) && value >= 0) return Math.floor(value) - return 0 +/** Read a send-once flag from storage. Any truthy value means already sent. */ +export function alreadySent(value: unknown): boolean { + return value === true || value === 1 || value === "1" } diff --git a/src/index.ts b/src/index.ts index f7d3dff..baaf6e6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,36 +1,34 @@ /** * opencode-plugin-dsml-fix — recover DeepSeek DSML tool calls that leak as text. * - * Strategy matrix (each independently configurable, see `src/config.ts`): - * - B1 `strategies.stream`: streaming middleware recovery via ctx.aisdk language hook. - * - B2 `strategies.generate`: non-streaming generate recovery (same hook). - * - `strategies.sanitize`: strip leaked DSML spans from replayed history. - * - `strategies.directive`: preventive correction directive in system. - * - `strategies.systemNudge`: conditional correction nudge in system when history - * ends in unrecovered DSML (transient, never persisted). - * - `strategies.watchdog`: idle watchdog that wakes stalled sessions; the wake - * travels on `resume.channel` ("system" = minimal ping + system nudge, - * "user" = legacy full-text synthetic turn). + * Four features (see README), each independently configurable: + * - `parse`: tolerant parser — complete blocks plus orphan invokes, loose + * parameters, raw-JSON bodies, wrapped tools. Same flags govern live + * recovery and history stain detection. + * - `history`: strip leaked spans from replayed assistant text (+ note). + * Never synthesizes calls from history (no double execution). + * - `responseFix`: turn leaked markup into real calls on the live response, + * native SSE path and AI-SDK path alike. + * - `retry`: conditional correction nudge plus one idle wake per message. * * Invariant: the plugin must never break normal inference. Unknown shapes pass - * through untouched, and every strategy degrades to a safer subset via config. + * through untouched, and every layer degrades to a safer subset via config. */ import { Plugin } from "@opencode/plugin" import { appendFileSync } from "node:fs" import { appliesToProvider, resolveConfig, type DsmlPluginOptions } from "./config.ts" import { + alreadySent, attemptKey, decideResume, messageText, needsSystemNudge, - parseAttempts, turnId, wakeText, } from "./fallback.ts" import { createDsmlStreamMiddleware, wrapDsmlLanguageModel } from "./middleware.ts" import { parseDsml, redactionNote, stripRangesWithNote, type ParseOptions } from "./parse.ts" -import { preventiveDirective } from "./prompt.ts" import { rewriteSseResponse } from "./sse.ts" const PLUGIN_ID = "dsml" @@ -70,8 +68,8 @@ export default Plugin.define({ // `session/message` identity. Attempts count physical requests within a turn. const turns = new Map() - // --- Strategies B1+B2: model recovery ------------------------------------ - if (config.streamEnabled || config.generateEnabled) { + // --- Live response fix: model recovery ------------------------------------ + if (config.responseFixEnabled) { await ctx.aisdk.hook("language", (event) => { try { appendFileSync( @@ -97,8 +95,8 @@ export default Plugin.define({ } event.language = wrapDsmlLanguageModel(base, { ...parseOptions, - stream: config.streamEnabled, - generate: config.generateEnabled, + stream: true, + generate: true, bufferLimit: config.bufferLimit, onSettled: (stats) => { const what = @@ -110,12 +108,12 @@ export default Plugin.define({ }) } - // --- Strategy B3: SSE rewrite on the native protocol path ------------------ + // --- Live response fix: SSE rewrite on the native protocol path ------------ // Catalog providers such as opencode-go resolve to the NATIVE // openai-compatible route, where `aisdk.language` never fires. The native // route offers every request to the http hooks, so rewrite the SSE bytes // there instead. Same parser, same policy, different wire format. - if (config.sseEnabled) { + if (config.responseFixEnabled) { await ctx.session.hook("http.response", (event) => { if (!appliesToProvider(config, event.model.providerID)) return if (event.kind !== "primary") return @@ -144,7 +142,7 @@ export default Plugin.define({ }) } - // --- Request strategies: directive + sanitize + system nudge ------------ + // --- Request shaping: sanitize + conditional nudge ------------------------- await ctx.session.hook("context", async (event) => { if (!appliesToProvider(config, event.model.providerID)) return // A new user message starts a new turn: reset the attempt counter so the @@ -155,12 +153,7 @@ export default Plugin.define({ const label = `${event.sessionID}/${turn}` let sanitizedMsgs = 0 let sanitizedParts = 0 - let directive = false let nudge = false - if (config.directiveEnabled) { - event.system.push({ type: "text", text: preventiveDirective() }) - directive = true - } if (config.sanitizeEnabled) { // Strip leaked DSML spans from replayed assistant text so the model does // not imitate its own degraded output. A short inline note marks each @@ -188,30 +181,29 @@ export default Plugin.define({ if (touched) sanitizedMsgs++ } } - if (config.systemNudgeEnabled) { + if (config.retryNudgeEnabled) { const need = needsSystemNudge(event.messages, parseOptions) if (need) { const key = attemptKey(event.sessionID, need.key) - const attempts = parseAttempts(await ctx.storage.get(key)) - if (attempts >= config.resumeMaxAttempts) { - log("system nudge cap reached for", need.key) + if (alreadySent(await ctx.storage.get(key))) { + log("nudge already sent for", need.key) } else { - await ctx.storage.set(key, attempts + 1) + await ctx.storage.set(key, true) event.system.push({ type: "text", text: need.text }) nudge = true } } } // One line per request turn: what each request layer actually did. - if (sanitizedMsgs > 0 || directive || nudge) { + if (sanitizedMsgs > 0 || nudge) { log( - `request ${label} sanitized=${sanitizedMsgs}msgs/${sanitizedParts}parts directive=${directive ? "yes" : "no"} nudge=${nudge ? "yes" : "no"}`, + `request ${label} sanitized=${sanitizedMsgs}msgs/${sanitizedParts}parts nudge=${nudge ? "yes" : "no"}`, ) } }) - // --- Strategy: idle watchdog ---------------------------------------------- - if (!config.watchdogEnabled) return + // --- Retry: one idle wake per stalled message ------------------------------- + if (!config.retryEnabled) return const controller = new AbortController() void (async () => { try { @@ -254,22 +246,22 @@ async function maybeWake( }) if (!text) return const key = attemptKey(sessionID, last.id) - const attempts = parseAttempts(await ctx.storage.get(key)) - const decision = decideResume(text, attempts, config.resumeMaxAttempts) + const sent = alreadySent(await ctx.storage.get(key)) + const decision = decideResume(text, sent) if (!decision.send || !decision.text) { if (decision.reason && decision.reason !== "no-marker") log(decision.reason, "for", last.id) return } - await ctx.storage.set(key, attempts + 1) - if (config.resumeChannel === "user") { - // Legacy: the full correction travels as a synthetic user turn. + await ctx.storage.set(key, true) + if (config.retryChannel === "user") { + // The full correction travels as a synthetic user turn. await ctx.session.synthetic({ sessionID, text: decision.text }) } else { // System channel: minimal wake ping; the context hook attaches the full // correction as a transient system instruction on the woken turn. await ctx.session.synthetic({ sessionID, text: wakeText(), description: "dsml-retry wake" }) } - log("sent wake for", last.id, `attempt ${attempts + 1} channel=${config.resumeChannel}`) + log("sent wake for", last.id, `channel=${config.retryChannel}`) } export { createDsmlStreamMiddleware } diff --git a/src/parse.ts b/src/parse.ts index ae247c5..44a276e 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -246,7 +246,8 @@ function parseParameters(body: string, options: Required): Record< const strict = collectParameters(body, parameterRe()) // If the strict scan produced nothing, or one value ran away into the next // parameter (the vLLM mis-closed-closer leak), retry with the tolerant scan. (V11) - // The tolerant scan is a relaxed-mode rescue; strict mode keeps the strict result. + // The tolerant scan applies when `looseParameters` is on; otherwise the + // strict result stands as-is. const ranAway = Object.values(strict).some((value) => typeof value === "string" && value.includes("DSML")) const picked = options.looseParameters && (Object.keys(strict).length === 0 || ranAway) diff --git a/src/prompt.ts b/src/prompt.ts index a18f621..54292a5 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -1,38 +1,15 @@ /** - * Correction directives: the text the plugin shows the model so it emits tool calls - * in the correct format with the outer XML layer. + * Recovery-failure nudge: the text the plugin shows the model so it re-issues a + * tool call that arrived malformed. * - * Writing rule: these strings are *sent to the model*, so they describe the grammar + * Writing rule: this string is *sent to the model*, so it describes the grammar * without containing a live DSML token. A literal token here would be parsed as a - * phantom call by greedy provider extractors. The fixer tests assert the strings - * contain no marker bytes. + * phantom call by greedy provider extractors. The fixer tests assert the string + * contains no marker bytes. */ -export const MARKER_DESCRIPTION = - "a DeepSeek DSML tag uses single full-width bars around the word DSML " + - "(open angle bracket, U+FF5C, the letters DSML, U+FF5C, then the tag name)" - /** - * Preventive instruction, installed via the `context` hook as a system part. Tells - * the model the exact envelope so degraded forms are less likely in the first place. - */ -export function preventiveDirective(): string { - return [ - "Tool-call format (must follow exactly):", - "1. Wrap every tool call in a complete outer block layer: an opening block tag", - " for tool_calls, then one invoke element per call, then the closing block tag.", - `2. Each tag is ${MARKER_DESCRIPTION}. Never use doubled bars, never use plain ASCII pipes, never leave a space where the tag name belongs.`, - "3. Each invoke element carries the tool name in its name attribute and contains", - ' one parameter element per argument, with string="true" for plain text values', - ' and string="false" for JSON values.', - "4. Never emit a parameter element without its enclosing invoke element, and never", - " emit an invoke element without the outer block layer. Incomplete markup will", - " not be executed.", - ].join("\n") -} - -/** - * Recovery-failure nudge, sent (via Strategy A) when DSML markup appeared but no call + * Recovery-failure nudge, sent when DSML markup appeared but no call * could be recovered, or when a session idles on leaked markup. Short, imperative, * and self-contained: it restates the required outer layer. */ @@ -47,7 +24,7 @@ export function recoveryNudge(toolHint?: string): string { ].join(" ") } -/** Guard used by tests: neither directive may contain a live marker. */ +/** Guard used by tests: model-facing text must contain no live marker. */ export function containsLiveMarker(text: string): boolean { return /[\uFF5C|]\s*DSML\s*[\uFF5C|]/i.test(text) } diff --git a/test/config.test.ts b/test/config.test.ts index 8c31788..bd4d714 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -1,36 +1,49 @@ -/** Config, mode gating, prompt guards, and fallback decisions. */ +/** Config defaults, per-shape parse flags, prompt guards, and retry decisions. */ import { describe, expect, test } from "bun:test" import { appliesToProvider, resolveConfig } from "../src/config.ts" -import { attemptKey, decideResume, messageText, needsSystemNudge, nudgeCapKey, parseAttempts, wakeText } from "../src/fallback.ts" +import { alreadySent, attemptKey, decideResume, messageText, needsSystemNudge, nudgeCapKey, turnId, wakeText } from "../src/fallback.ts" import { block, invoke, param } from "./fixtures.ts" import { parseDsml, redactionNote, stripRangesWithNote } from "../src/parse.ts" -import { containsLiveMarker, preventiveDirective, recoveryNudge } from "../src/prompt.ts" +import { containsLiveMarker, recoveryNudge } from "../src/prompt.ts" describe("config", () => { - test("defaults target both Zen providers in relaxed mode", () => { + test("defaults: everything protective on, only debug off", () => { const c = resolveConfig() expect([...c.providers]).toEqual(["opencode-go", "opencode"]) - expect(c.relaxed).toBe(true) expect(c.orphanInvoke).toBe(true) - expect(c.watchdogEnabled).toBe(true) - expect(c.resumeMaxAttempts).toBe(3) - expect(c.directiveEnabled).toBe(true) - expect(c.resumeChannel).toBe("system") - }) - - test("strict mode disables every rescue", () => { - const c = resolveConfig({ mode: "strict" }) - expect(c.relaxed).toBe(false) - expect(c.orphanInvoke).toBe(false) - expect(c.looseParameters).toBe(false) - expect(c.rawJsonBody).toBe(false) - }) - - test("one rescue can be switched off in isolation", () => { - const c = resolveConfig({ rescue: { orphanInvoke: false } }) - expect(c.orphanInvoke).toBe(false) expect(c.looseParameters).toBe(true) + expect(c.rawJsonBody).toBe(true) + expect(c.wrappedTool).toBe(true) + expect(c.responseFixEnabled).toBe(true) + expect(c.bufferLimit).toBe(64 * 1024) + expect(c.sanitizeEnabled).toBe(true) + expect(c.retryEnabled).toBe(true) + expect(c.retryNudgeEnabled).toBe(true) + expect(c.retryChannel).toBe("system") + expect(c.debug).toBe(false) + }) + + test("one parse shape can be switched off in isolation", () => { + const c = resolveConfig({ parse: { wrappedTool: false } }) + expect(c.wrappedTool).toBe(false) + expect(c.orphanInvoke).toBe(true) + }) + + test("whole layers switch off", () => { + const c = resolveConfig({ + responseFix: { enabled: false }, + history: { sanitize: false }, + retry: { enabled: false, nudge: false }, + }) + expect(c.responseFixEnabled).toBe(false) + expect(c.sanitizeEnabled).toBe(false) + expect(c.retryEnabled).toBe(false) + expect(c.retryNudgeEnabled).toBe(false) + }) + + test("retry channel selects delivery", () => { + expect(resolveConfig({ retry: { channel: "user" } }).retryChannel).toBe("user") }) test("empty providers disables scoping", () => { @@ -47,104 +60,66 @@ describe("config", () => { }) }) -describe("strategy matrix", () => { - test("each transport recovers independently", () => { - const off = resolveConfig({ recovery: { sse: false } }) - expect(off.sseEnabled).toBe(false) - expect(off.generateEnabled).toBe(true) - const all = resolveConfig({ - recovery: { sse: false, aisdkStream: false, aisdkGenerate: false }, - request: { sanitize: false, directive: false, systemNudge: false }, - }) - expect(all.streamEnabled).toBe(false) - expect(all.generateEnabled).toBe(false) - expect(all.sanitizeEnabled).toBe(false) - expect(all.directiveEnabled).toBe(false) - expect(all.systemNudgeEnabled).toBe(false) - }) +describe("parse flags", () => { + const allOff = { orphanInvoke: false, looseParameters: false, rawJsonBody: false, wrappedTool: false } - test("recovery/request win over legacy strategies.*", () => { - expect(resolveConfig({ strategies: { sse: false } }).sseEnabled).toBe(false) - expect( - resolveConfig({ strategies: { sse: false }, response: { sse: true } }).sseEnabled, - ).toBe(true) - expect( - resolveConfig({ strategies: { sanitize: false }, request: { sanitize: true } }).sanitizeEnabled, - ).toBe(true) - }) - - test("legacy aliases still work; new names win when set", () => { - expect(resolveConfig({ resume: { enabled: false } }).watchdogEnabled).toBe(false) - expect(resolveConfig({ prompt: { enabled: false } }).directiveEnabled).toBe(false) - expect(resolveConfig({ resume: { enabled: false }, strategies: { watchdog: true } }).watchdogEnabled).toBe(true) - expect(resolveConfig({ resume: { channel: "user" } }).resumeChannel).toBe("user") - }) -}) - -describe("strict mode parsing", () => { - const strict = { orphanInvoke: false, looseParameters: false, rawJsonBody: false } - - test("complete block still parses", () => { - const r = parseDsml(block([invoke("bash", [param("command", "pwd")])]), strict) + test("complete block still parses with every flag off", () => { + const r = parseDsml(block([invoke("bash", [param("command", "pwd")])]), allOff) expect(r.calls).toHaveLength(1) }) - test("orphan invoke is not recovered", () => { - const r = parseDsml(invoke("edit", [param("path", "a")]), strict) + test("orphan invoke is not recovered with its flag off", () => { + const r = parseDsml(invoke("edit", [param("path", "a")]), allOff) expect(r.calls).toHaveLength(0) }) - test("mis-closed parameter is not rescued", () => { + test("mis-closed parameter is not rescued with its flag off", () => { const a = `<\uFF5CDSML\uFF5Cparameter name="alpha" string="true">first` const src = block([invoke("r", [a, param("beta", "second")])]) - const r = parseDsml(src, strict) - // Strict keeps the strict scan as-is (runaway value), never the tolerant one. + const r = parseDsml(src, allOff) + // Without the tolerant scan the strict result stands (runaway value). expect(r.calls.map((c) => c.name)).toEqual(["r"]) }) }) describe("prompt", () => { - test("directives contain no live marker", () => { - expect(containsLiveMarker(preventiveDirective())).toBe(false) + test("nudge contains no live marker and states the outer-layer rule", () => { expect(containsLiveMarker(recoveryNudge("edit"))).toBe(false) - }) - - test("directive states the outer-layer rule", () => { - expect(preventiveDirective()).toContain("outer block") expect(recoveryNudge()).toContain("outer") }) }) -describe("fallback", () => { +describe("retry", () => { test("no marker → no nudge", () => { - expect(decideResume("hello world", 0, 3)).toEqual({ send: false, reason: "no-marker" }) + expect(decideResume("hello world", false)).toEqual({ send: false, reason: "no-marker" }) }) - test("recoverable text → no nudge (middleware owns it)", () => { - const decision = decideResume(block([invoke("bash", [param("command", "pwd")])]), 0, 3) + test("recoverable text → no nudge (live fix owns it)", () => { + const decision = decideResume(block([invoke("bash", [param("command", "pwd")])]), false) expect(decision.send).toBe(false) expect(decision.reason).toBe("already-recoverable") }) test("unrecoverable markup → nudge with correction text", () => { - const decision = decideResume("broken \uFF5CDSML\uFF5C tool_calls garbage", 0, 3) + const decision = decideResume("broken \uFF5CDSML\uFF5C tool_calls garbage", false) expect(decision.send).toBe(true) expect(decision.text).toContain("outer") }) - test("cap is enforced", () => { - const decision = decideResume("broken \uFF5CDSML\uFF5C tool_calls garbage", 3, 3) - expect(decision).toEqual({ send: false, reason: "cap-reached" }) + test("send-once: already poked → silence", () => { + const decision = decideResume("broken \uFF5CDSML\uFF5C tool_calls garbage", true) + expect(decision).toEqual({ send: false, reason: "already-sent" }) }) test("attempt key is namespaced per message", () => { expect(attemptKey("s1", "m1")).not.toBe(attemptKey("s1", "m2")) }) - test("parseAttempts tolerates garbage", () => { - expect(parseAttempts(undefined)).toBe(0) - expect(parseAttempts("x")).toBe(0) - expect(parseAttempts(2)).toBe(2) + test("alreadySent reads the flag", () => { + expect(alreadySent(undefined)).toBe(false) + expect(alreadySent(false)).toBe(false) + expect(alreadySent(true)).toBe(true) + expect(alreadySent(1)).toBe(true) }) test("messageText reads assistant text only", () => { @@ -170,7 +145,7 @@ describe("system nudge", () => { expect(needsSystemNudge([{ role: "assistant", content: [{ type: "text", text: "done" }] }])).toBeUndefined() }) - test("silent when the middleware could recover it", () => { + test("silent when the live fix could recover it", () => { const nudge = needsSystemNudge([ { role: "assistant", content: [{ type: "text", text: block([invoke("bash", [param("command", "pwd")])]) }] }, ]) @@ -205,13 +180,9 @@ describe("system nudge", () => { ).toBe("no-user-turn") }) - test("sseEnabled defaults true; response.sse wins", () => { - expect(resolveConfig().sseEnabled).toBe(true) - expect(resolveConfig({ response: { sse: false } }).sseEnabled).toBe(false) - expect(resolveConfig({ recovery: { sse: false } }).sseEnabled).toBe(false) - expect( - resolveConfig({ recovery: { sse: false }, response: { sse: true } }).sseEnabled, - ).toBe(true) + test("responseFix defaults on", () => { + expect(resolveConfig().responseFixEnabled).toBe(true) + expect(resolveConfig({ responseFix: { enabled: false } }).responseFixEnabled).toBe(false) }) })