mirror of
https://github.com/bendtherules/opencode-plugin-dsml-fix.git
synced 2026-10-17 16:56:50 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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("")
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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 & b "q"")])]))
|
||||
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")
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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 ")
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user