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
+236
View File
@@ -0,0 +1,236 @@
/** Config, mode gating, prompt guards, and fallback 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 { block, invoke, param } from "./fixtures.ts"
import { parseDsml, redactionNote, stripRangesWithNote } from "../src/parse.ts"
import { containsLiveMarker, preventiveDirective, recoveryNudge } from "../src/prompt.ts"
describe("config", () => {
test("defaults target both Zen providers in relaxed mode", () => {
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)
})
test("empty providers disables scoping", () => {
const c = resolveConfig({ providers: [] })
expect(appliesToProvider(c, "opencode-go")).toBe(false)
})
test("provider scoping", () => {
const c = resolveConfig()
expect(appliesToProvider(c, "opencode-go")).toBe(true)
expect(appliesToProvider(c, "opencode")).toBe(true)
expect(appliesToProvider(c, "anthropic")).toBe(false)
expect(appliesToProvider(c, undefined)).toBe(false)
})
})
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)
})
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)
expect(r.calls).toHaveLength(1)
})
test("orphan invoke is not recovered", () => {
const r = parseDsml(invoke("edit", [param("path", "a")]), strict)
expect(r.calls).toHaveLength(0)
})
test("mis-closed parameter is not rescued", () => {
const a = `<\uFF5CDSML\uFF5Cparameter name="alpha" string="true">first</\uFF5CDSML\uFF5C>`
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.
expect(r.calls.map((c) => c.name)).toEqual(["r"])
})
})
describe("prompt", () => {
test("directives contain no live marker", () => {
expect(containsLiveMarker(preventiveDirective())).toBe(false)
expect(containsLiveMarker(recoveryNudge("edit"))).toBe(false)
})
test("directive states the outer-layer rule", () => {
expect(preventiveDirective()).toContain("outer block")
expect(recoveryNudge()).toContain("outer")
})
})
describe("fallback", () => {
test("no marker → no nudge", () => {
expect(decideResume("hello world", 0, 3)).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)
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)
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("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("messageText reads assistant text only", () => {
expect(messageText({ role: "user", content: [{ type: "text", text: "hi" }] })).toBeUndefined()
expect(messageText({ role: "assistant", content: [{ type: "text", text: "a" }] })).toBe("a")
})
test("messageText tolerates null text", () => {
expect(messageText({ role: "assistant", content: [{ type: "text", text: null }] })).toBeUndefined()
})
})
describe("system nudge", () => {
const unrecoverable = `trailing \uFF5CDSML\uFF5C tool_calls garbage without structure`
test("fires when history ends in unrecoverable DSML", () => {
const nudge = needsSystemNudge([{ role: "assistant", content: [{ type: "text", text: unrecoverable }] }])
expect(nudge?.text).toContain("outer")
expect(nudge?.key).toBeTruthy()
})
test("silent for clean history", () => {
expect(needsSystemNudge([{ role: "assistant", content: [{ type: "text", text: "done" }] }])).toBeUndefined()
})
test("silent when the middleware could recover it", () => {
const nudge = needsSystemNudge([
{ role: "assistant", content: [{ type: "text", text: block([invoke("bash", [param("command", "pwd")])]) }] },
])
expect(nudge).toBeUndefined()
})
test("prefers message id, falls back to content hash", () => {
expect(nudgeCapKey({ id: "msg-1", role: "assistant", content: [{ type: "text", text: "x" }] })).toBe("msg-1")
const a = nudgeCapKey({ role: "assistant", content: [{ type: "text", text: "same" }] })
const b = nudgeCapKey({ role: "assistant", content: [{ type: "text", text: "same" }] })
const c = nudgeCapKey({ role: "assistant", content: [{ type: "text", text: "different" }] })
expect(a).toBe(b)
expect(a).not.toBe(c)
})
test("wake text is minimal", () => {
expect(wakeText().length).toBeLessThan(60)
})
test("turnId uses OpenCode's user message id", async () => {
const { turnId } = await import("../src/fallback.ts")
expect(
turnId([
{ role: "user", content: [{ type: "text", text: "first" }], id: "msg-aaa" },
{ role: "assistant", content: [{ type: "text", text: "reply" }], id: "msg-bbb" },
{ role: "user", content: [{ type: "text", text: "second" }], id: "msg-ccc" },
]),
).toBe("msg-ccc")
expect(turnId([])).toBe("no-user-turn")
expect(
turnId([{ role: "assistant", content: [{ type: "text", text: "x" }] }]),
).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)
})
})
describe("redaction note", () => {
test("placeholder replaces each removed span", () => {
expect(stripRangesWithNote("a <b>c", [{ start: 2, end: 7 }], "[removed malformed edit call]")).toBe(
"a [removed malformed edit call]",
)
expect(stripRangesWithNote("a <b>c", [{ start: 2, end: 5 }])).toBe("a [malformed tool call removed]c")
})
test("note carries no marker bytes", () => {
const out = stripRangesWithNote("x", [{ start: 0, end: 1 }], "[removed malformed edit call]")
expect(containsLiveMarker(out)).toBe(false)
})
test("redactionNote names tools, caps length", () => {
expect(redactionNote(["edit"])).toBe("[removed malformed edit call]")
expect(redactionNote([])).toBe("[removed malformed markup]")
expect(containsLiveMarker(redactionNote(["edit", "shell"]))).toBe(false)
})
})
+58
View File
@@ -0,0 +1,58 @@
/**
* Fixture builders for DSML parser tests.
*
* The live DSML marker contains U+FF5C (full-width vertical bar). We never write
* that byte literally in this repository: models and greedy provider extractors
* treat it as a tool-call start, and a literal token in a source file becomes a
* phantom call. Everything is built from `BAR` at runtime instead.
*/
/** U+FF5C full-width vertical bar. */
export const BAR = String.fromCharCode(0xff5c)
/** ASCII pipe, the other bar the grammar accepts. */
export const PIPE = "|"
/** Canonical marker: `<|DSML|>`. */
export const marker = (bar = BAR, count = 1) => `${bar.repeat(count)}DSML${bar.repeat(count)}`
/** Mangled / doubled marker, as emitted by gateways that re-decode tokens. */
export const markerDoubled = () => marker(BAR, 2)
export const markerAscii = () => "|DSML|"
/** A DSML tag: `<MARKERname attrs...>`. */
export const tag = (name: string, attrs = "", m = marker()) => `<${m}${name}${attrs ? " " + attrs : ""}>`
/** A closing DSML tag. */
export const closeTag = (name: string, m = marker()) => `</${m}${name}>`
/** A parameter element. `string=false` encodes a JSON value. */
export const param = (name: string, value: string, opts: { json?: boolean; m?: string; quote?: string } = {}) => {
const m = opts.m ?? marker()
const q = opts.quote ?? '"'
const stringAttr = opts.json ? ` string="false"` : ` string="true"`
return `${tag("parameter", `name=${q}${name}${q}${stringAttr}`, m)}${value}${closeTag("parameter", m)}`
}
/** An invoke element wrapping zero or more parameters. */
export const invoke = (name: string, params: string[] = [], opts: { m?: string; quote?: string } = {}) => {
const m = opts.m ?? marker()
const q = opts.quote ?? '"'
return `${tag("invoke", `name=${q}${name}${q}`, m)}${params.join("")}${closeTag("invoke", m)}`
}
/** A tool_calls block wrapping one or more invokes. */
export const block = (
invokes: string[],
opts: { m?: string; kind?: string } = {},
) => {
const m = opts.m ?? marker()
const kind = opts.kind ?? "tool_calls"
return `${tag(kind, "", m)}${invokes.join("")}${closeTag(kind, m)}`
}
/** The end-of-sentence sentinel some backends emit. */
export const eos = (bar = BAR) => `<${bar.repeat(2)}end\u2581of\u2581sentence${bar.repeat(2)}>`
/** Concatenate fragments (makes intent explicit at call sites). */
export const text = (...parts: string[]) => parts.join("")
+118
View File
@@ -0,0 +1,118 @@
/**
* Golden test: 9 real-world DSML leak shapes captured from a long-context
* DeepSeek session (wrapped edit/shell calls, seqs 5177–5695), preserved as
* synthetic byte-faithful fixtures (`test/golden/db-messages.json`, generated by
* `test/golden/build-fixtures.ts`). No private content: paths, code, and prose
* are generic. Each message is run through the parser AND the streaming
* middleware.
*
* Expectations per message (verified against the DB by hand):
* - 5177, 5425, 5443, 5624, 5647, 5663, 5679, 5695: finish=stop, orphan/degraded
* markup. The parser must recover the intended call where one is unambiguous
* (edit/shell with parseable params) or strip the noise otherwise; the stream
* must emit identical text plus the recovered call and flip finish to tool-calls.
* - 5608: finish=tool-calls with leaked markup alongside — parser recovers the call,
* leaked text is stripped, finish stays tool-calls.
*/
import { describe, expect, test } from "bun:test"
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"
import { parseDsml, stripRanges } from "../src/parse.ts"
import { createDsmlStreamMiddleware } from "../src/middleware.ts"
import raw from "./golden/db-messages.json" with { type: "json" }
interface GoldenMessage {
seq: number
finish: string
text: string
}
const messages = raw as GoldenMessage[]
function splitDeltas(text: string, size: number): string[] {
const parts: string[] = []
for (let i = 0; i < text.length; i += size) parts.push(text.slice(i, i + size))
return parts
}
async function runStream(text: string) {
const chunks = splitDeltas(text, 7) // awkward split: cuts through markers
const parts: LanguageModelV3StreamPart[] = [
{ type: "stream-start", warnings: [] },
{ type: "text-start", id: "t1" },
...chunks.map((delta): LanguageModelV3StreamPart => ({ type: "text-delta", id: "t1", delta })),
{ type: "text-end", id: "t1" },
{ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: {} as never },
]
const stream = new ReadableStream<LanguageModelV3StreamPart>({
start(c) {
for (const p of parts) c.enqueue(p)
c.close()
},
})
const middleware = createDsmlStreamMiddleware()
const wrapped = await middleware.wrapStream!({
doStream: async () => ({ stream }),
doGenerate: (async () => ({})) as never,
params: {} as never,
model: {} as never,
})
const events: LanguageModelV3StreamPart[] = []
const reader = wrapped.stream.getReader()
for (;;) {
const { done, value } = await reader.read()
if (done) break
events.push(value)
}
return events
}
describe("golden: DB session leaks", () => {
test("all 9 fixtures load with DSML content", () => {
expect(messages).toHaveLength(9)
for (const m of messages) expect(m.text).toMatch(/DSML/)
})
for (const m of messages) {
test(`seq ${m.seq}: stream preserves text, recovers call, flips finish`, async () => {
const parsed = parseDsml(m.text)
const events = await runStream(m.text)
const textOut = events
.filter((e): e is Extract<LanguageModelV3StreamPart, { type: "text-delta" }> => e.type === "text-delta")
.map((e) => e.delta)
.join("")
const calls = events.filter((e) => e.type === "tool-call")
const finish = events.find((e) => e.type === "finish") as Extract<
LanguageModelV3StreamPart,
{ type: "finish" }
>
// No text is ever lost: emitted text + stripped ranges === input minus calls.
// Concretely: every non-markup word of the input must appear in the output.
// (Comparison ignores quote chars: call args travel JSON-escaped in parts.)
const inputWords = m.text.replace(/<[^>]*>/g, " ").split(/\s+/).filter(Boolean)
const haystacks = [textOut, JSON.stringify(calls)].map((h) => h.replace(/["\\]/g, ""))
for (const word of inputWords) {
if (word.length > 20) continue // long values may be inside recovered args
const bare = word.replace(/["\\]/g, "")
expect(haystacks.some((h) => h.includes(bare))).toBe(true)
}
// Parser and stream agree on the call count.
expect(calls.length).toBe(parsed.calls.length)
if (parsed.calls.length > 0) {
expect(finish.finishReason.unified).toBe("tool-calls")
} else {
expect(finish.finishReason.unified).toBe("stop")
}
// Stripped output contains no marker.
expect(stripRanges(m.text, parsed.ranges)).not.toMatch(/DSML/)
})
}
test("seq 5608 (native tool-calls): wrapped edit call recovered", async () => {
const m = messages.find((x) => x.seq === 5608)!
const parsed = parseDsml(m.text)
expect(parsed.calls.length).toBeGreaterThan(0)
expect(parsed.calls[0]!.name).toBe("edit")
expect(parsed.calls[0]!.arguments).toHaveProperty("newString")
})
})
+128
View File
@@ -0,0 +1,128 @@
/**
* Build synthetic golden fixtures: same 9 structural shapes as the real failures,
* with generic content. Run with `bun test/golden/build-fixtures.ts` and redirect
* to `test/golden/db-messages.json`.
*
* Shapes covered: wrapped edit/shell with markerless outer (V26), marked outer,
* spaced closers, multiline values, shell metachars, one native tool-calls case.
* Real bytes are assembled at runtime (never stored literally).
*/
const BAR = String.fromCharCode(0xff5c)
const M = `${BAR}DSML${BAR}`
const param = (name: string, value: string, m = M, json = false) =>
`<${m}parameter name="${name}" string="${json ? "false" : "true"}">${value}</${m}parameter>`
interface Golden {
seq: number
finish: string
text: string
}
const messages: Golden[] = [
{
seq: 5177,
finish: "stop",
text:
`Now collect the player state reactively and add the seek callback. Let me update the screen.\n\n` +
`<analysis>\nCollect state in onCreate and pass it into the settings screen.\n</analysis>\n\n` +
`<parameter name="edit">\n` +
`${param("newString", " // Mirror the player state into a snapshot the UI reads.\n playerState.collect { state.value = it }")}\n` +
`${param("oldString", " playerState.collect { state.value = it }")}\n` +
`${param("path", "/proj/app/src/main/java/com/example/app/MainScreen.kt")}\n` +
`</${M} invoke>\n</${M} calls>`,
},
{
seq: 5425,
finish: "stop",
text:
`The renderer joins pieces when the text buffer grows. Let me find where that happens.\n\n` +
`<analysis>\nFind the append call sites in the render loop.\n</analysis>\n\n` +
`<parameter name="shell">\n` +
`${param("command", "cd /proj/native\ngrep -n 'append' src/render.cpp | head -20")}\n` +
`</${M} invoke>\n</${M} calls>`,
},
{
seq: 5443,
finish: "stop",
text:
`The join rule handles the boundary marker. Let me inspect the decoder.\n\n` +
`<analysis>\nInspect how boundary markers turn into separators.\n</analysis>\n\n` +
`<parameter name="shell">\n` +
`${param("command", "cd /proj/native\ngrep -rn 'decode' src/*.cpp src/*.hpp | head\necho \"=== impl ===\"\ngrep -rn 'std::string decode' src/")}\n` +
`</${M} invoke>\n</${M} calls>`,
},
{
seq: 5608,
finish: "tool-calls",
text:
`The worker pool defaults to 8 threads, which oversubscribes a phone. Let me expose a thread-count knob with a sensible default.\n\n` +
`<analysis>\nAdd a small native setter and call it before first inference.\n</analysis>\n\n` +
`<${M}parameter name="edit">\n` +
`${param("newString", '#include "engine_capi.h"\n\nnamespace rt { void set_num_threads(int n); }')}\n` +
`${param("oldString", '#include "engine_capi.h"')}\n` +
`${param("path", "/proj/app/src/main/cpp/engine_jni.cpp")}\n` +
`</${M}invoke>`,
},
{
seq: 5624,
finish: "stop",
text:
`Call the thread setter during preload, tuned to the fast cores.\n\n` +
`<analysis>\nSet threads once before loading, capped to the big cluster.\n</analysis>\n\n` +
`<parameter name="edit">\n` +
`${param("newString", " loading = true\n val threads = (cores / 2).coerceIn(2, 6)\n try { Native.setThreads(threads) } catch (_: Throwable) { }")}\n` +
`${param("oldString", " loading = true")}\n` +
`${param("path", "/proj/app/src/main/java/com/example/app/engine/Model.kt")}\n` +
`</${M} invoke>\n</${M} calls>`,
},
{
seq: 5647,
finish: "stop",
text:
`The declaration may have landed inside the wrong scope. Let me check its position.\n\n` +
`<analysis>\nVerify the declaration sits at file scope, not inside a block.\n</analysis>\n\n` +
`<parameter name="shell">\n` +
`${param("command", "cd /proj/app/src/main/cpp\ngrep -n 'namespace rt' engine_jni.cpp | head")}\n` +
`</${M} invoke>\n</${M} calls>`,
},
{
seq: 5663,
finish: "stop",
text:
`The declaration didn't apply. Let me add it right after the include.\n\n` +
`<analysis>\nInsert after the capi include with a unique anchor.\n</analysis>\n\n` +
`<parameter name="edit">\n` +
`${param("newString", '#include "engine_capi.h"\n\nnamespace rt { void set_num_threads(int n); }')}\n` +
`${param("oldString", '#include "engine_capi.h"')}\n` +
`${param("path", "/proj/app/src/main/cpp/engine_jni.cpp")}\n` +
`</${M} invoke>\n</${M} calls>`,
},
{
seq: 5679,
finish: "stop",
text:
`Odd scoping at the call site. Let me check line numbers around it.\n\n` +
`<analysis>\nCheck declaration location versus call site ordering.\n</analysis>\n\n` +
`<parameter name="shell">\n` +
`${param("command", "cd /proj/app/src/main/cpp\ngrep -n 'set_num_threads' engine_jni.cpp")}\n` +
`</${M} invoke>\n</${M} calls>`,
},
{
seq: 5695,
finish: "stop",
text:
`The declaration is still missing. Let me write it directly.\n\n` +
`<analysis>\nInsert with a precise unique anchor this time.\n</analysis>\n\n` +
`<parameter name="edit">\n` +
`${param("newString", '#include "engine_capi.h"\n\nnamespace rt { void set_num_threads(int n); }')}\n` +
`${param("oldString", '#include "engine_capi.h"')}\n` +
`${param("path", "/proj/app/src/main/cpp/engine_jni.cpp")}\n` +
`</${M} invoke>\n</${M} calls>`,
},
]
// Doubled-bar shape (`<||DSML||…>`) is covered in test/stream.test.ts and
// test/parse.test.ts with runtime-built markers.
console.log(JSON.stringify(messages))
File diff suppressed because one or more lines are too long
+255
View File
@@ -0,0 +1,255 @@
/**
* Parser tests. One `test(...)` per row in docs/TEST-MATRIX.md.
* Fixtures are built at runtime (test/fixtures.ts) so no live DSML token is stored.
*/
import { describe, expect, test } from "bun:test"
import { parseDsml, stripRanges } from "../src/parse.ts"
import { BAR, block, closeTag, eos, invoke, marker, markerAscii, markerDoubled, param, tag, text } from "./fixtures.ts"
const args = (calls: { name: string; arguments: Record<string, unknown> }[], i = 0) => calls[i]!.arguments
const one = (result: { calls: unknown[] }) => {
expect(result.calls).toHaveLength(1)
return result.calls[0] as { name: string; arguments: Record<string, unknown> }
}
describe("parseDsml — variants", () => {
test("v1-canonical", () => {
const src = text("before ", block([invoke("bash", [param("command", "ls -la")])]), " after")
const r = parseDsml(src)
expect(one(r).name).toBe("bash")
expect(args(r.calls)).toEqual({ command: "ls -la" })
expect(r.ranges).toHaveLength(1)
expect(stripRanges(src, r.ranges)).toBe("before after")
})
test("v2-doubled-bars", () => {
const m = markerDoubled()
const r = parseDsml(block([invoke("bash", [param("command", "pwd", { m })], { m })], { m }))
expect(one(r).name).toBe("bash")
})
test("v3-ascii-pipes", () => {
const m = markerAscii()
const r = parseDsml(block([invoke("bash", [param("command", "pwd", { m })], { m })], { m }))
expect(one(r).name).toBe("bash")
})
test("v4-marker-space (space before mangled tag)", () => {
// <BAR DSML BAR calls>
const open = `<${marker()} calls>`
const close = `</${marker()} calls>`
const src = open + invoke("bash", [param("command", "pwd")]) + close
const r = parseDsml(src)
expect(one(r).name).toBe("bash")
expect(stripRanges(src, r.ranges)).toBe("")
})
test("v5-truncated-tag (bare `calls`)", () => {
const r = parseDsml(block([invoke("bash", [param("command", "pwd")])], { kind: "calls" }))
expect(one(r).name).toBe("bash")
})
test("v6-name-spacing", () => {
const src = `${tag("tool_calls")}<${marker()}invoke name = "bash">${param("command", "pwd")}${closeTag("invoke")}${closeTag("tool_calls")}`
const r = parseDsml(src)
expect(one(r).name).toBe("bash")
})
test("v7-tool-call-singular", () => {
const r = parseDsml(block([invoke("read", [param("path", "a.ts")])], { kind: "tool_call" }))
expect(one(r).name).toBe("read")
})
test("v8-function-calls (V3.2)", () => {
const r = parseDsml(block([invoke("glob", [param("pattern", "*.ts")])], { kind: "function_calls" }))
expect(one(r).name).toBe("glob")
})
test("v9-orphan-invoke (no outer block)", () => {
const src = text("prose ", invoke("edit", [param("path", "Main.kt")]), " tail")
const r = parseDsml(src)
expect(one(r).name).toBe("edit")
expect(stripRanges(src, r.ranges)).toBe("prose tail")
})
test("v10-orphan-parameter (no invoke) — strip, no call", () => {
const src = text("x ", tag("parameter", 'name="edit" string="true"'), "body", closeTag("parameter"), " y")
const r = parseDsml(src)
expect(r.calls).toHaveLength(0)
expect(stripRanges(src, r.ranges)).toBe("x y")
})
test("v11-misspelled-closer does not swallow the next parameter", () => {
// <BAR DSML BAR parameter name="alpha" string="true">first</BAR DSML BAR> <BAR DSML BAR parameter name="beta" ...>second...
const a = `<${marker()}parameter name="alpha" string="true">first</${marker()}>`
const b = param("beta", "second")
const src = block([invoke("record_item", [a, b])])
const r = parseDsml(src)
expect(one(r).arguments).toEqual({ beta: "second" })
})
test("v12-runaway-name — no call, strip", () => {
const src = text("x ", tag("invoke", 'name="record_item {"'), "\ncategory: Dexes</plan>")
const r = parseDsml(src)
expect(r.calls).toHaveLength(0)
expect(r.ranges.length).toBeGreaterThan(0)
})
test("v13-undeclared-tool is kept", () => {
const r = parseDsml(block([invoke("totally_made_up", [param("x", "y")])]))
expect(one(r).name).toBe("totally_made_up")
})
test("v14-empty-name — no trap", () => {
const r = parseDsml(block([invoke("", [param("x", "y")])]))
expect(r.calls).toHaveLength(0)
})
test("v15-unclosed block flushes, keeps complete invokes", () => {
const open = tag("tool_calls")
const src = text("p ", open, invoke("bash", [param("command", "pwd")]), " trailing")
const r = parseDsml(src)
expect(one(r).name).toBe("bash")
// Everything from the opener to the end is ranged away.
expect(stripRanges(src, r.ranges)).toBe("p")
})
test("v16-truncated-invoke keeps earlier calls", () => {
const open = tag("tool_calls")
const src = text(open, invoke("a", [param("x", "1")]), tag("invoke", 'name="b"'), param("y", "2"))
const r = parseDsml(src)
expect(r.calls.map((c) => c.name)).toEqual(["a"])
})
test("v18-surrounding text preserved", () => {
const src = text("hello ", block([invoke("bash", [param("command", "pwd")])]), " world")
const r = parseDsml(src)
expect(stripRanges(src, r.ranges)).toBe("hello world")
})
test("v19-multiple invokes and blocks", () => {
const src = text(
block([invoke("a", [param("x", "1")]), invoke("b", [param("y", "2")])]),
" mid ",
block([invoke("c", [param("z", "3")]), invoke("d", [param("w", "4")])]),
)
const r = parseDsml(src)
expect(r.calls.map((c) => c.name)).toEqual(["a", "b", "c", "d"])
})
test("v20-code-fence ignored", () => {
const src = text("```\n", block([invoke("bash", [param("command", "rm -rf /")])]), "\n```")
const r = parseDsml(src)
expect(r.calls).toHaveLength(0)
expect(r.ranges).toHaveLength(0)
})
test("v21-eos stripped", () => {
const src = text(block([invoke("a", [param("x", "1")])]), eos())
const r = parseDsml(src)
expect(one(r).name).toBe("a")
expect(stripRanges(src, r.ranges)).toBe("")
})
test("v22-entities decoded", () => {
const r = parseDsml(block([invoke("bash", [param("command", "a &amp; b &quot;q&quot;")])]))
expect(one(r).arguments).toEqual({ command: 'a & b "q"' })
})
test("v23-multiline value preserved", () => {
const value = "\nline1\n line2\n"
const r = parseDsml(block([invoke("edit", [param("content", value)])]))
expect(one(r).arguments).toEqual({ content: "line1\n line2" })
})
test("v24-json parameter", () => {
const r = parseDsml(block([invoke("q", [param("questions", '[{"a":1}]', { json: true })])]))
expect(one(r).arguments).toEqual({ questions: [{ a: 1 }] })
})
test("v25-raw-json-body", () => {
const src = `${tag("tool_calls")}<${marker()}invoke name="q">{"questions":[1,2]}${closeTag("invoke")}${closeTag("tool_calls")}`
const r = parseDsml(src)
expect(one(r).arguments).toEqual({ questions: [1, 2] })
})
test("v26-wrapped-tool (markerless outer, DB 5177 shape)", () => {
const src = text(
"prose ",
`<parameter name="edit">`,
param("newString", "a"),
param("oldString", "b"),
param("path", "f.kt"),
`${closeTag("invoke")}`,
)
const r = parseDsml(src)
expect(one(r).name).toBe("edit")
expect(one(r).arguments).toEqual({ newString: "a", oldString: "b", path: "f.kt" })
expect(stripRanges(src, r.ranges)).toBe("prose")
})
test("v26-wrapped-tool with marked outer (DB 5608 shape)", () => {
const m = marker()
const src = text(
`<${m}parameter name="shell">`,
param("command", "pwd"),
`</${m}invoke>`,
)
const r = parseDsml(src)
expect(one(r).name).toBe("shell")
expect(one(r).arguments).toEqual({ command: "pwd" })
})
test("v26-plain prose parameter untouched", () => {
const src = "the <parameter> element holds a value"
const r = parseDsml(src)
expect(r.calls).toHaveLength(0)
expect(r.ranges).toHaveLength(0)
})
test("v26-nested incomplete inner → no call", () => {
const src = text(
`<parameter name="edit">`,
tag("parameter", 'name="newString" string="true"'),
"half-written",
)
const r = parseDsml(src)
expect(r.calls).toHaveLength(0)
})
})
describe("parseDsml — reject arms", () => {
test("reject-no-marker", () => {
expect(parseDsml("hello world")).toEqual({ calls: [], ranges: [] })
})
test("reject-not-dsml fast path", () => {
expect(parseDsml("a".repeat(10000))).toEqual({ calls: [], ranges: [] })
})
test("reject-empty-args", () => {
expect(parseDsml(block([invoke("bash", [])])).calls).toHaveLength(0)
})
})
describe("parseDsml — captured real shape", () => {
test("stream-multiline-edit (DB seq 5177 shape) recovers the wrapped call", () => {
// The real leak: an orphan `parameter name="edit"` wrapping complete inner
// params. V26 recovers it as a real call (V13 policy: runtime validates).
const src = text(
`\u2039/analysis\u203a\n\n`,
tag("parameter", 'name="edit"'),
"\n",
param("newString", "val x = 1"),
"\n",
param("oldString", "val x = 0"),
"\n",
closeTag("invoke"),
)
const r = parseDsml(src)
expect(r.calls).toHaveLength(1)
expect(r.calls[0]!.name).toBe("edit")
expect(stripRanges(src, r.ranges)).toBe("\u2039/analysis\u203a")
})
})
+300
View File
@@ -0,0 +1,300 @@
/**
* SSE rewriter tests. Mirror of test/stream.test.ts, asserting on the rewritten
* OpenAI-Chat SSE bytes — the exact contract the native protocol parser reads.
* Plus a golden run over the real DB failure messages serialized as SSE.
*/
import { describe, expect, test } from "bun:test"
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"
import { SseDsmlRewriter } from "../src/sse.ts"
import { createDsmlStreamMiddleware } from "../src/middleware.ts"
import { BAR, block, invoke, marker, param, tag, text } from "./fixtures.ts"
import raw from "./golden/db-messages.json" with { type: "json" }
function sseText(delta: string): string {
return `data: ${JSON.stringify({ choices: [{ delta: { content: delta }, index: 0 }] })}\n\n`
}
function sseFinish(reason: string): string {
return `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: reason, index: 0 }] })}\n\n`
}
/** Feed whole frames; split the byte stream awkwardly to simulate chunking. */
function runStream(bytes: string, split = 7): string[] {
const rewriter = new SseDsmlRewriter()
const out: string[] = []
for (let i = 0; i < bytes.length; i += split) {
out.push(...rewriter.push(bytes.slice(i, i + split)))
}
out.push(...rewriter.flush())
return out
}
/** Parse emitted frames back into events for assertions. */
function parseOut(frames: string[]): Array<{ content?: string; toolCall?: { name: string; args: string }; finish?: string }> {
const events: Array<{ content?: string; toolCall?: { name: string; args: string }; finish?: string }> = []
const calls = new Map<number, { name?: string; args: string }>()
for (const frame of frames) {
for (const line of frame.split("\n")) {
const payload = line.startsWith("data:") ? line.slice(5).trimStart() : line
if (!payload || payload === "[DONE]") continue
const event = JSON.parse(payload) as {
choices?: Array<{ delta?: { content?: string; tool_calls?: Array<{ index: number; id?: string; function?: { name?: string; arguments?: string } }> }; finish_reason?: string }>
}
const choice = event.choices?.[0]
if (!choice) continue
if (typeof choice.delta?.content === "string") events.push({ content: choice.delta.content })
for (const tc of choice.delta?.tool_calls ?? []) {
const entry = calls.get(tc.index) ?? { args: "" }
if (tc.function?.name) entry.name = tc.function.name
if (tc.function?.arguments) entry.args += tc.function.arguments
calls.set(tc.index, entry)
}
if (typeof choice.finish_reason === "string") events.push({ finish: choice.finish_reason })
}
}
for (const call of calls.values()) {
if (call.name) events.push({ toolCall: { name: call.name, args: call.args } })
}
return events
}
const textOf = (events: ReturnType<typeof parseOut>) =>
events.filter((e) => e.content !== undefined).map((e) => e.content as string).join("")
const callsOf = (events: ReturnType<typeof parseOut>) => events.filter((e) => e.toolCall !== undefined)
const finishOf = (events: ReturnType<typeof parseOut>) => events.find((e) => e.finish !== undefined)?.finish
describe("sse rewriter", () => {
test("plain text passes through with identical bytes", () => {
const bytes = sseText("Hello, ") + sseText("world!") + sseFinish("stop") + "data: [DONE]\n\n"
const events = parseOut(runStream(bytes, 11))
expect(textOf(events)).toBe("Hello, world!")
expect(callsOf(events)).toHaveLength(0)
expect(finishOf(events)).toBe("stop")
})
test("recovers a doubled-bar call split across chunks, flips finish", () => {
const doubles = marker(BAR, 2)
expect(doubles).toContain("DSML")
const body = `<${doubles}tool_calls><${doubles}invoke name="bash"><${doubles}parameter name="command" string="true">ls -la</${doubles}parameter></${doubles}invoke></${doubles}tool_calls>`
const bytes = sseText(body) + sseFinish("stop") + "data: [DONE]\n\n"
const events = parseOut(runStream(bytes, 13))
const calls = callsOf(events)
expect(calls).toHaveLength(1)
expect(calls[0]!.toolCall!.name).toBe("bash")
expect(JSON.parse(calls[0]!.toolCall!.args)).toEqual({ command: "ls -la" })
expect(textOf(events)).toBe("")
expect(textOf(events)).not.toContain("DSML")
expect(finishOf(events)).toBe("tool_calls")
})
test("recovers the wrapped-tool shape (DB degradation)", () => {
const bytes = sseText(text("pre ", `<parameter name="edit">`, param("path", "Main.kt"), " post")) + sseFinish("stop") + "data: [DONE]\n\n"
const events = parseOut(runStream(bytes))
const calls = callsOf(events)
expect(calls).toHaveLength(1)
expect(calls[0]!.toolCall!.name).toBe("edit")
expect(finishOf(events)).toBe("tool_calls")
})
test("unparseable markup passes through, finish untouched", () => {
const bytes = sseText(text(tag("tool_calls"), tag("invoke", 'name="x"'))) + sseFinish("stop") + "data: [DONE]\n\n"
const events = parseOut(runStream(bytes))
expect(callsOf(events)).toHaveLength(0)
expect(finishOf(events)).toBe("stop")
})
test("non-SSE payloads pass through", () => {
const rewriter = new SseDsmlRewriter()
const out = [...rewriter.push('{"error":{"message":"boom"}}'), ...rewriter.flush()]
expect(out.join("")).toContain("boom")
})
test("usage chunks pass through", () => {
const bytes = sseText("hi") + `data: ${JSON.stringify({ usage: { prompt_tokens: 3 } })}\n\n` + sseFinish("stop") + "data: [DONE]\n\n"
const events = parseOut(runStream(bytes))
expect(textOf(events)).toBe("hi")
})
test("combined content+finish in one event keeps both", () => {
// Some gateways terminate the stream with content and finish_reason together.
// Dropping either breaks the run ("stream ended without finish_reason").
const bytes =
sseText("done") +
`data: ${JSON.stringify({ choices: [{ delta: { content: "!" }, finish_reason: "stop", index: 0 }] })}\n\n` +
"data: [DONE]\n\n"
const events = parseOut(runStream(bytes))
expect(textOf(events)).toBe("done!")
expect(finishOf(events)).toBe("stop")
})
})
describe("settled stats", () => {
test("sse rewriter reports recovery counts once", () => {
const seen: Array<{ calls: number; sawCandidate: boolean }> = []
const rewriter = new SseDsmlRewriter({ onSettled: (s) => seen.push(s) })
const bytes = sseText(block([invoke("bash", [param("command", "pwd")])])) + sseFinish("stop") + "data: [DONE]\n\n"
for (let i = 0; i < bytes.length; i += 23) rewriter.push(bytes.slice(i, i + 23))
rewriter.flush()
rewriter.flush()
expect(seen).toHaveLength(1)
expect(seen[0]!.calls).toBe(1)
expect(seen[0]!.sawCandidate).toBe(true)
})
test("sse rewriter reports pure passthrough", () => {
const seen: Array<{ calls: number; sawCandidate: boolean }> = []
const rewriter = new SseDsmlRewriter({ onSettled: (s) => seen.push(s) })
rewriter.push(sseText("plain text here") + sseFinish("stop"))
rewriter.flush()
expect(seen).toHaveLength(1)
expect(seen[0]).toEqual({ calls: 0, sawCandidate: false })
})
test("middleware reports settled stats", async () => {
const seen: Array<{ calls: number; sawCandidate: boolean }> = []
const middleware = createDsmlStreamMiddleware({ onSettled: (s) => seen.push(s) })
const source = new ReadableStream<LanguageModelV3StreamPart>({
start(c) {
c.enqueue({ type: "stream-start", warnings: [] })
c.enqueue({ type: "text-start", id: "t1" })
c.enqueue({ type: "text-delta", id: "t1", delta: "just prose" })
c.enqueue({ type: "text-end", id: "t1" })
c.enqueue({ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: {} as never })
c.close()
},
})
const wrapped = await middleware.wrapStream!({
doStream: async () => ({ stream: source }),
doGenerate: (async () => ({})) as never,
params: {} as never,
model: {} as never,
})
const reader = wrapped.stream.getReader()
for (;;) {
const { done } = await reader.read()
if (done) break
}
expect(seen).toEqual([{ calls: 0, sawCandidate: false }])
})
})
describe("parallel safety", () => {
test("concurrent streams produce unique call ids", async () => {
const bodies = Array.from(
{ length: 8 },
(_, i) => block([invoke(`tool${i}`, [param("x", String(i))])]),
)
const runOne = async (body: string) => {
const middleware = createDsmlStreamMiddleware()
const stream = new ReadableStream<LanguageModelV3StreamPart>({
start(c) {
c.enqueue({ type: "stream-start", warnings: [] })
c.enqueue({ type: "text-start", id: "t1" })
c.enqueue({ type: "text-delta", id: "t1", delta: body })
c.enqueue({ type: "text-end", id: "t1" })
c.enqueue({ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: {} as never })
c.close()
},
})
const wrapped = await middleware.wrapStream!({
doStream: async () => ({ stream }),
doGenerate: (async () => ({})) as never,
params: {} as never,
model: {} as never,
})
const ids: string[] = []
const reader = wrapped.stream.getReader()
for (;;) {
const { done, value } = await reader.read()
if (done) break
if (value.type === "tool-call") ids.push(value.toolCallId)
}
return ids
}
const all = (await Promise.all(bodies.map(runOne))).flat()
expect(all).toHaveLength(8)
expect(new Set(all).size).toBe(8)
})
test("concurrent sse rewriters keep separate state", () => {
const a = new SseDsmlRewriter()
const b = new SseDsmlRewriter()
const outA = a.push(sseText(block([invoke("ta", [param("x", "1")])])) )
const outB = b.push(sseText("plain prose, no markup here") + sseFinish("stop"))
// A already resolved its complete block; B (plain prose) is unaffected.
expect(a.recoveredCalls).toBe(true)
expect(outA.join("")).not.toMatch(/DSML/)
expect(b.recoveredCalls).toBe(false)
const allB = outB.join("") + b.flush().join("")
expect(allB).toContain("plain prose")
})
})
describe("sse golden: DB session leaks", () => {
interface GoldenMessage {
seq: number
finish: string
text: string
}
const messages = raw as GoldenMessage[]
for (const m of messages) {
test(`seq ${m.seq}: recovers call end to end over SSE`, () => {
// One content delta per 64 chars (coarse chunking: structure mostly whole).
let bytes = ""
for (let i = 0; i < m.text.length; i += 64) bytes += sseText(m.text.slice(i, i + 64))
bytes += sseFinish("stop") + "data: [DONE]\n\n"
const events = parseOut(runStream(bytes, 97))
const calls = callsOf(events)
expect(calls.length).toBeGreaterThan(0)
expect(["edit", "shell"]).toContain(calls[0]!.toolCall!.name)
expect(finishOf(events)).toBe("tool_calls")
expect(textOf(events)).not.toMatch(/DSML/)
})
}
test("mixed native + leaked call keeps both with distinct indexes", () => {
// The provider emits a proper native call AND leaks a DSML block as text
// (the Bedrock #45600 shape). Both must survive with distinct index/id.
const nativeCall = `data: ${JSON.stringify({
choices: [{ delta: { tool_calls: [{ index: 0, id: "call_native1", function: { name: "bash", arguments: '{"command":"pwd"}' } }] }, index: 0 }],
})}\n\n`
const leak = sseText(text("checking: ", block([invoke("read", [param("path", "a.ts")])])))
const bytes = nativeCall + leak + sseFinish("stop") + "data: [DONE]\n\n"
const rewriter = new SseDsmlRewriter()
const frames = [...rewriter.push(bytes), ...rewriter.flush()].join("")
// Collect every (index, id/name, arguments) triple from the output stream.
const seen: Array<{ index: number; id?: string; name?: string; args?: string }> = []
for (const line of frames.split("\n")) {
if (!line.startsWith("data:")) continue
const payload = line.slice(5).trimStart()
if (!payload || payload === "[DONE]") continue
const event = JSON.parse(payload) as {
choices?: Array<{ delta?: { tool_calls?: Array<{ index: number; id?: string; function?: { name?: string; arguments?: string } }> } }>
}
for (const tc of event.choices?.[0]?.delta?.tool_calls ?? []) {
seen.push({ index: tc.index, id: tc.id, name: tc.function?.name, args: tc.function?.arguments })
}
}
const byIndex = new Map<number, typeof seen>()
for (const s of seen) byIndex.set(s.index, [...(byIndex.get(s.index) ?? []), s])
// Native index 0 carries only the native call; synthetic lives elsewhere.
const nativeParts = (byIndex.get(0) ?? []).filter((s) => s.id === "call_native1")
expect(nativeParts.length).toBeGreaterThan(0)
expect(nativeParts.every((s) => s.name === undefined || s.name === "bash")).toBe(true)
const synthIndexes = [...byIndex.keys()].filter((i) => i !== 0)
expect(synthIndexes.length).toBe(1)
const synthParts = byIndex.get(synthIndexes[0]!)!
// Name chunk carries the generated id; continuation chunks share the index.
expect(synthParts.some((s) => s.name === "read")).toBe(true)
const synthArgs = synthParts.map((s) => s.args ?? "").join("")
expect(JSON.parse(synthArgs)).toEqual({ path: "a.ts" })
// No native part may carry synthetic arguments and vice versa.
expect(nativeParts.map((s) => s.args ?? "").join("")).not.toContain("a.ts")
})
test("block helper sanity", () => {
expect(block([invoke("bash", [param("command", "pwd")])])).toContain("tool_calls")
})
})
+223
View File
@@ -0,0 +1,223 @@
/**
* Streaming middleware tests. One `test(...)` per row in docs/TEST-MATRIX.md under
* "Streaming". We drive the middleware the same way `wrapLanguageModel` does, and
* assert on the emitted AI-SDK parts — the exact contract OpenCode's lowering reads.
*/
import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"
import { describe, expect, test } from "bun:test"
import { createDsmlStreamMiddleware } from "../src/middleware.ts"
import { BAR, block, closeTag, invoke, marker, param, tag, text } from "./fixtures.ts"
function source(deltas: string[], finish: "stop" | "tool-calls" = "stop") {
const parts: LanguageModelV3StreamPart[] = [
{ type: "stream-start", warnings: [] },
{ type: "text-start", id: "t1" },
...deltas.map((delta): LanguageModelV3StreamPart => ({ type: "text-delta", id: "t1", delta })),
{ type: "text-end", id: "t1" },
{ type: "finish", finishReason: { unified: finish, raw: finish }, usage: {} as never },
]
return new ReadableStream<LanguageModelV3StreamPart>({
start(controller) {
for (const part of parts) controller.enqueue(part)
controller.close()
},
})
}
async function run(deltas: string[], finish: "stop" | "tool-calls" = "stop") {
const middleware = createDsmlStreamMiddleware()
const wrapped = await middleware.wrapStream!({
doStream: async () => ({ stream: source(deltas, finish) }),
doGenerate: (async () => ({})) as never,
params: {} as never,
model: {} as never,
})
const events: LanguageModelV3StreamPart[] = []
const reader = wrapped.stream.getReader()
for (;;) {
const { done, value } = await reader.read()
if (done) break
events.push(value)
}
return events
}
const streamedText = (events: LanguageModelV3StreamPart[]) =>
events.filter((e): e is Extract<LanguageModelV3StreamPart, { type: "text-delta" }> => e.type === "text-delta").map((e) => e.delta).join("")
const toolCalls = (events: LanguageModelV3StreamPart[]) =>
events.filter((e): e is Extract<LanguageModelV3StreamPart, { type: "tool-call" }> => e.type === "tool-call")
const finishOf = (events: LanguageModelV3StreamPart[]) =>
events.find((e): e is Extract<LanguageModelV3StreamPart, { type: "finish" }> => e.type === "finish")!
const lifecycle = (events: LanguageModelV3StreamPart[]) =>
events.filter((e) => e.type === "tool-input-start" || e.type === "tool-input-delta" || e.type === "tool-input-end").map((e) => e.type)
describe("streaming middleware", () => {
test("stream-text-passthrough", async () => {
const events = await run(["Hello, ", "world!"])
expect(streamedText(events)).toBe("Hello, world!")
expect(toolCalls(events)).toHaveLength(0)
expect(finishOf(events).finishReason.unified).toBe("stop")
})
test("stream-v2-recovered (doubled bars, chunked per token)", async () => {
// The captured CherryStudio SSE shape: every marker character split.
const doubles = marker(BAR, 2)
const deltas = [
"<",
`${BAR}${BAR}`,
"DS",
"ML",
`${BAR}${BAR}`,
"tool",
"_c",
"alls",
">",
"<",
`${BAR}${BAR}`,
"DS",
"ML",
`${BAR}${BAR}`,
'invoke name="bash">',
"<",
`${BAR}${BAR}`,
"DS",
"ML",
`${BAR}${BAR}`,
'parameter name="command" string="true">ls -la',
"</",
`${BAR}${BAR}`,
"DS",
"ML",
`${BAR}${BAR}`,
"parameter>",
"</",
`${BAR}${BAR}`,
"DS",
"ML",
`${BAR}${BAR}`,
"invoke>",
"</",
`${BAR}${BAR}`,
"DS",
"ML",
`${BAR}${BAR}`,
"tool_calls>",
]
expect(doubles).toContain("DSML")
const events = await run(deltas)
const calls = toolCalls(events)
expect(calls).toHaveLength(1)
expect(calls[0]!.toolName).toBe("bash")
expect(JSON.parse(calls[0]!.input)).toEqual({ command: "ls -la" })
expect(streamedText(events)).toBe("")
expect(streamedText(events)).not.toContain("DSML")
})
test("stream-finish-rewrite", async () => {
const events = await run([block([invoke("bash", [param("command", "pwd")])])])
expect(finishOf(events).finishReason.unified).toBe("tool-calls")
})
test("stream-finish-untouched", async () => {
const events = await run(["no tools here"])
expect(finishOf(events).finishReason.unified).toBe("stop")
})
test("stream-unclosed-outer-but-complete-invoke is recovered (V9/V15)", async () => {
// Outer block never closes, but the inner invoke is complete. Recovering the
// unambiguous call beats dropping it — this matches the DB degradation.
const deltas = [tag("tool_calls"), invoke("bash", [param("command", "pwd")])]
const events = await run(deltas)
expect(toolCalls(events)).toHaveLength(1)
expect(toolCalls(events)[0]!.toolName).toBe("bash")
})
test("stream-truncated-invoke falls back to text", async () => {
// Opener plus an invoke that never closes: nothing usable, so flush verbatim.
const deltas = [tag("tool_calls"), tag("invoke", 'name="bash"'), param("command", "pwd")]
const events = await run(deltas)
expect(toolCalls(events)).toHaveLength(0)
expect(streamedText(events)).toContain("DSML")
expect(finishOf(events).finishReason.unified).toBe("stop")
})
test("stream-empty-invoke-fallback", async () => {
const deltas = [block([tag("invoke", 'name="x"')])]
const events = await run(deltas)
expect(toolCalls(events)).toHaveLength(0)
expect(finishOf(events).finishReason.unified).toBe("stop")
})
test("stream-surrounding", async () => {
const events = await run(["a ", block([invoke("bash", [param("command", "pwd")])]), " b"])
expect(toolCalls(events)).toHaveLength(1)
expect(streamedText(events)).toBe("a b")
})
test("stream-orphan-invoke (V9 degradation)", async () => {
const events = await run(["pre ", invoke("edit", [param("path", "Main.kt")]), " post"])
const calls = toolCalls(events)
expect(calls).toHaveLength(1)
expect(calls[0]!.toolName).toBe("edit")
})
test("lifecycle ordering around each call", async () => {
const events = await run([block([invoke("a", [param("x", "1")])])])
expect(lifecycle(events)).toEqual(["tool-input-start", "tool-input-delta", "tool-input-end"])
})
test("stream-buffer-cap falls back to text", async () => {
const huge = tag("tool_calls") + "x".repeat(70 * 1024)
const events = await run([huge])
expect(toolCalls(events)).toHaveLength(0)
expect(streamedText(events)).toContain("DSML")
})
test("stream-eager-stop: prose opener flushes early, nothing held", async () => {
// A wrapped-candidate opener that never develops call structure: the hold
// must release as text (eager stop), not wait for text-end.
const events = await run(['<parameter name="oops"> just prose, no structure here'])
expect(toolCalls(events)).toHaveLength(0)
// Text is emitted (possibly across two deltas: head + flushed hold), fully.
expect(streamedText(events)).toBe('<parameter name="oops"> just prose, no structure here')
expect(finishOf(events).finishReason.unified).toBe("stop")
})
test("stream-transition: foreign part flushes held text first", async () => {
const middleware = createDsmlStreamMiddleware()
const parts: LanguageModelV3StreamPart[] = [
{ type: "stream-start", warnings: [] },
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: "hello " },
{ type: "reasoning-start", id: "r1" },
{ type: "reasoning-delta", id: "r1", delta: "thinking" },
{ type: "reasoning-end", id: "r1" },
{ type: "text-end", id: "t1" },
{ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: {} as never },
]
const stream = new ReadableStream<LanguageModelV3StreamPart>({
start(c) {
for (const p of parts) c.enqueue(p)
c.close()
},
})
const wrapped = await middleware.wrapStream!({
doStream: async () => ({ stream }),
doGenerate: (async () => ({})) as never,
params: {} as never,
model: {} as never,
})
const events: LanguageModelV3StreamPart[] = []
const reader = wrapped.stream.getReader()
for (;;) {
const { done, value } = await reader.read()
if (done) break
events.push(value)
}
const order = events.map((e) => e.type)
// "hello " text must precede the reasoning block (no reordering).
expect(order.indexOf("text-delta")).toBeLessThan(order.indexOf("reasoning-start"))
expect(streamedText(events)).toBe("hello ")
})
})
+96
View File
@@ -0,0 +1,96 @@
/** The dependency-free language-model wrapper: stream + generate recovery. */
import type { LanguageModelV3, LanguageModelV3CallOptions } from "@ai-sdk/provider"
import { describe, expect, test } from "bun:test"
import { recoverGenerateResult, wrapDsmlLanguageModel } from "../src/middleware.ts"
import { block, invoke, param } from "./fixtures.ts"
function fakeModel(deltas: string[], finish: "stop" | "tool-calls" = "stop"): LanguageModelV3 {
return {
specificationVersion: "v3",
provider: "opencode-go",
modelId: "deepseek-v4.1-flash",
supportedUrls: {},
async doGenerate() {
return {
content: [{ type: "text", text: deltas.join("") }],
finishReason: { unified: finish, raw: finish },
usage: { inputTokens: { total: 1 }, outputTokens: { total: 1 } },
warnings: [],
}
},
async doStream() {
const parts = [
{ type: "stream-start", warnings: [] },
{ type: "text-start", id: "t1" },
...deltas.map((delta) => ({ type: "text-delta", id: "t1", delta })),
{ type: "text-end", id: "t1" },
{ type: "finish", finishReason: { unified: finish, raw: finish }, usage: {} },
]
return {
stream: new ReadableStream({
start(c) {
for (const p of parts) c.enqueue(p)
c.close()
},
}),
}
},
} as unknown as LanguageModelV3
}
const opts = {} as unknown as LanguageModelV3CallOptions
async function collect(stream: ReadableStream) {
const out: Array<{ type: string; [k: string]: unknown }> = []
const reader = stream.getReader()
for (;;) {
const { done, value } = await reader.read()
if (done) break
out.push(value)
}
return out
}
describe("wrapDsmlLanguageModel", () => {
test("stream recovers a call and flips finish", async () => {
const wrapped = wrapDsmlLanguageModel(fakeModel([block([invoke("bash", [param("command", "pwd")])])]))
const result = await wrapped.doStream(opts)
const events = await collect(result.stream as ReadableStream)
expect(events.some((e) => e.type === "tool-call")).toBe(true)
const finish = events.find((e) => e.type === "finish") as unknown as { finishReason: { unified: string } }
expect(finish.finishReason.unified).toBe("tool-calls")
})
test("plain text passes through untouched", async () => {
const wrapped = wrapDsmlLanguageModel(fakeModel(["hello world"]))
const result = await wrapped.doStream(opts)
const events = await collect(result.stream as ReadableStream)
expect(events.some((e) => e.type === "tool-call")).toBe(false)
expect(events.filter((e) => e.type === "text-delta").map((e) => e.delta).join("")).toBe("hello world")
})
test("generate recovers a call", async () => {
const wrapped = wrapDsmlLanguageModel(fakeModel(["lead ", block([invoke("q", [param("a", "1")])]), " tail"]))
const result = await wrapped.doGenerate(opts)
expect(result.content.some((p) => p.type === "tool-call")).toBe(true)
expect(result.finishReason.unified).toBe("tool-calls")
})
test("delegates identity fields", () => {
const wrapped = wrapDsmlLanguageModel(fakeModel([]))
expect(wrapped.provider).toBe("opencode-go")
expect(wrapped.modelId).toBe("deepseek-v4.1-flash")
expect(wrapped.specificationVersion).toBe("v3")
})
})
describe("recoverGenerateResult", () => {
test("unparseable block returns input unchanged", () => {
const content = [{ type: "text", text: "plain" }]
const finishReason = { unified: "stop", raw: "stop" } as const
const r = recoverGenerateResult(content as never, finishReason)
expect(r.recovered).toBe(0)
expect(r.finishReason.unified).toBe("stop")
})
})