diff --git a/src/adapters/codebuddy/scaffold-guard.ts b/src/adapters/codebuddy/scaffold-guard.ts index 6f8c80aed5..707f90ae1c 100644 --- a/src/adapters/codebuddy/scaffold-guard.ts +++ b/src/adapters/codebuddy/scaffold-guard.ts @@ -5,10 +5,10 @@ export const CODEBUDDY_SCAFFOLD_ERROR_CODE = "vendor_scaffold_detected"; // The observed control protocol uses FULLWIDTH VERTICAL LINE (U+FF5C). Detection stays // deliberately narrower than the marker spelling: a calls control line must be followed by an -// invoke line for a functions.* tool. That distinguishes an agent scaffold from prose quoting or +// invoke line with a non-empty tool name. That distinguishes an agent scaffold from prose quoting or // discussing one tag. const DSML_CALLS_LINE = "<||dsml|| calls>"; -const DSML_INVOKE_PREFIX = "<||dsml|| invoke name=\"functions."; +const DSML_INVOKE_PREFIX = "<||dsml|| invoke name=\""; export interface CodeBuddyScaffoldFilterResult { /** Bytes released from a suffix withheld by an earlier event on this channel. */ @@ -39,7 +39,7 @@ function prefixAtEnd(text: string, at: number, expected: string): boolean { * Control tags are recognized only at column zero and outside fenced Markdown. Inline code, * quoted strings, blockquotes, indented source, and prose all add syntax before the tag and are * therefore forwarded unchanged. A calls line alone is harmless; refusal requires the observed - * two-line calls-plus-functions-invoke grammar. + * two-line calls-plus-named-invoke grammar. */ function scan( text: string, @@ -77,7 +77,8 @@ function scan( if (invokeAt >= 0) { const invokeRest = text.slice(invokeAt).toLowerCase(); - if (invokeRest.startsWith(DSML_INVOKE_PREFIX)) { + const invokeNameStart = invokeRest[DSML_INVOKE_PREFIX.length]; + if (invokeRest.startsWith(DSML_INVOKE_PREFIX) && invokeNameStart && !/[\s"]/.test(invokeNameStart)) { return { safe: text.slice(0, index), held: "", fail: true, fence, lineStart }; } if (invokeRest.length === 0 || DSML_INVOKE_PREFIX.startsWith(invokeRest)) { diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 381276d04b..9223e4462d 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -355,7 +355,7 @@ real image blocks rather than flattening them to the text `[image]`, and orders blocks chronologically — history before current — so attachment order matches the prose the model reads beside them. Vendor tool execution stays disabled on both adapters. CodeBuddy refuses an unquoted, line-oriented full-width-bar DSML `calls` -container followed by a `functions.*` invoke control line in either output channel; it +container followed by a named bare or namespaced invoke control line in either output channel; it preserves preceding answer text, never promotes vendor prose into execution authority, and leaves discussed or quoted literals and code examples untouched. Qoder's explicit refusal of original images is unchanged. diff --git a/tests/providers/codebuddy-adapter.test.ts b/tests/providers/codebuddy-adapter.test.ts index 76caabfae9..fc784fe098 100644 --- a/tests/providers/codebuddy-adapter.test.ts +++ b/tests/providers/codebuddy-adapter.test.ts @@ -251,6 +251,29 @@ describe("codebuddy runTurn streams a headless turn", () => { expect(JSON.stringify(events)).not.toContain("secret-command"); }); + test.each(["Bash", "exec", "shell", "apply_patch"])("refuses a bare %s DSML invoke", name => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + + guarded({ + type: "text_delta", + text: `<||DSML|| calls>\n<||DSML|| invoke name="${name}">private-body`, + }); + + expect(events).toEqual([expect.objectContaining({ type: "error", code: "vendor_scaffold_detected" })]); + }); + + test("holds a bare invoke prefix split across deltas until its name arrives", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + + guarded({ type: "text_delta", text: "<||DSML|| calls>\n<||DSML|| invoke name=\"" }); + expect(events).toEqual([]); + guarded({ type: "text_delta", text: "Bash\">private-body" }); + + expect(events).toEqual([expect.objectContaining({ type: "error", code: "vendor_scaffold_detected" })]); + }); + test("detects a DSML control sequence split across streamed text deltas", async () => { const frame = (text: string) => `${JSON.stringify({ type: "stream_event", @@ -327,9 +350,9 @@ describe("codebuddy runTurn streams a headless turn", () => { const events: AdapterEvent[] = []; const guarded = guardCodeBuddyScaffolding(event => events.push(event)); const answer = "\"<||DSML|| calls>\"\n" - + "\"<||DSML|| invoke name=\\\"functions.exec\\\">\"\n" + + "\"<||DSML|| invoke name=\\\"Bash\\\">\"\n" + "Use `<||DSML|| calls>` when discussing the literal.\n" - + "> <||DSML|| calls>\n> <||DSML|| invoke name=\"functions.exec\">"; + + "> <||DSML|| calls>\n> <||DSML|| invoke name=\"exec\">"; guarded({ type: "text_delta", text: answer }); guarded({ type: "done", stopReason: "stop" }); @@ -344,7 +367,7 @@ describe("codebuddy runTurn streams a headless turn", () => { const events: AdapterEvent[] = []; const guarded = guardCodeBuddyScaffolding(event => events.push(event)); const first = "```text\n<||DSML|| calls>\n"; - const second = "<||DSML|| invoke name=\"functions.exec\">\n```"; + const second = "<||DSML|| invoke name=\"Bash\">\n```"; guarded({ type: "text_delta", text: first }); guarded({ type: "text_delta", text: second }); @@ -400,6 +423,20 @@ describe("codebuddy runTurn streams a headless turn", () => { ]); }); + test("delivers a calls block whose invoke name is empty", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + const answer = "<||DSML|| calls>\n<||DSML|| invoke name=\"\">"; + + guarded({ type: "text_delta", text: answer }); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "text_delta", text: answer }, + { type: "done", stopReason: "stop" }, + ]); + }); + test("queues later events behind an unresolved marker prefix", () => { const events: AdapterEvent[] = []; const guarded = guardCodeBuddyScaffolding(event => events.push(event));