mirror of
https://github.com/bendtherules/opencode-plugin-dsml-fix.git
synced 2026-10-17 16:56:50 +00:00
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.
119 lines
4.8 KiB
TypeScript
119 lines
4.8 KiB
TypeScript
/**
|
||
* 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")
|
||
})
|
||
})
|