Files
bendtherules 52b7776d5a 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.
2026-10-05 11:47:57 +05:30

119 lines
4.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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")
})
})