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
+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")
})
})