From 83a10258edcd4593aab7620a84929290b6185ebb Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Mon, 14 Sep 2026 13:51:00 +0000 Subject: [PATCH 1/5] fix(codebuddy): refuse leaked DSML scaffolding --- .../src/content/docs/guides/providers.md | 2 +- src/adapters/codebuddy/adapter.ts | 3 +- src/adapters/codebuddy/scaffold-guard.ts | 128 ++++++++++++++++++ structure/providers/chat-compat.md | 4 +- tests/providers/codebuddy-adapter.test.ts | 59 ++++++++ 5 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 src/adapters/codebuddy/scaffold-guard.ts diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index f45f653c39..ac9ba08ac7 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -759,7 +759,7 @@ OpenCodex provides official adapter support for Tencent Cloud's CodeBuddy Code C - Global: [CodeBuddy Global API Keys](https://www.codebuddy.ai/profile/keys) - CN: [CodeBuddy CN API Keys](https://copilot.tencent.com/profile/keys) - **Region Isolation:** `codebuddy` and `codebuddy-cn` use separate canonical endpoints (`https://www.codebuddy.ai` and `https://www.codebuddy.cn`) and isolated child environments (`CODEBUDDY_INTERNET_ENVIRONMENT=public` vs `internal`). Credentials are strictly region-scoped and never exchanged across environments. Overriding the canonical base URL fails closed. -- **Tool Ownership:** In v1, the CLI is spawned with `--tools ""` and `--strict-mcp-config`, ensuring Codex maintains exclusive tool ownership. The provider operates in text and reasoning mode; client tool execution is not delegated to the vendor CLI. +- **Tool Ownership:** In v1, the CLI is spawned with `--tools ""` and `--strict-mcp-config`, ensuring Codex maintains exclusive tool ownership. The provider operates in text and reasoning mode; client tool execution is not delegated to the vendor CLI. If the CLI writes DSML tool-call scaffolding into a text or reasoning stream anyway, OpenCodex refuses the turn instead of forwarding the markup or interpreting it as an executable call. - **Entitlements and Billing:** The provider uses the same vendor-documented CodeBuddy account/CLI authentication surface. Availability and billing of free, promotional, trial, or subscription credits remain determined by the user's CodeBuddy account entitlement. ### Official Qoder CLI (Global & CN) diff --git a/src/adapters/codebuddy/adapter.ts b/src/adapters/codebuddy/adapter.ts index 234e06907e..a769ac37da 100644 --- a/src/adapters/codebuddy/adapter.ts +++ b/src/adapters/codebuddy/adapter.ts @@ -4,6 +4,7 @@ import { mapReasoningEffort } from "../../reasoning-effort"; import { buildSystemPrompt } from "../coding-agent/protocol"; import { baseScopedEnv, runCodingAgentTurn, type CodingAgentDeps, type SpawnFn } from "../coding-agent/turn"; import { CODEBUDDY_PROFILES, type CodeBuddyProfile } from "./profiles"; +import { guardCodeBuddyScaffolding } from "./scaffold-guard"; export type { SpawnFn } from "../coding-agent/turn"; export type CodeBuddyAdapterDeps = CodingAgentDeps; @@ -75,7 +76,7 @@ export function createCodeBuddyAdapter(provider: OcxProviderConfig, deps: CodeBu provider, parsed, incoming, - emit, + emit: guardCodeBuddyScaffolding(emit), buildArgs: (resolved, req, prov) => buildArgs(resolved as CodeBuddyProfile, req, prov), buildEnv: (resolved, apiKey) => buildChildEnv(resolved as CodeBuddyProfile, apiKey), deps, diff --git a/src/adapters/codebuddy/scaffold-guard.ts b/src/adapters/codebuddy/scaffold-guard.ts new file mode 100644 index 0000000000..43099f9d43 --- /dev/null +++ b/src/adapters/codebuddy/scaffold-guard.ts @@ -0,0 +1,128 @@ +import type { AdapterEvent } from "../../types"; + +/** Error code for a CodeBuddy turn whose text channel contains vendor agent scaffolding. */ +export const CODEBUDDY_SCAFFOLD_ERROR_CODE = "vendor_scaffold_detected"; + +// CodeBuddy's observed DSML tags use FULLWIDTH VERTICAL LINE (U+FF5C), not ASCII pipes. +// Keep the exact spelling narrow: a bare "DSML" match would reject legitimate discussion of +// the protocol, while the tag prefix identifies vendor control markup rather than prose. +const DSML_OPEN = "<||dsml||"; +const DSML_CLOSE = " marker.length)); + +export interface CodeBuddyScaffoldFilterResult { + text: string; + fail: string | null; +} + +/** Longest suffix that may become an observed DSML marker after another stream delta. */ +function heldSuffixLength(text: string): number { + const limit = Math.min(MAX_MARKER_LENGTH - 1, text.length); + for (let length = limit; length > 0; length--) { + const suffix = text.slice(text.length - length).toLowerCase(); + if (MARKERS.some(marker => marker.startsWith(suffix))) return length; + } + return 0; +} + +/** + * Streaming fail-closed filter for one CodeBuddy text or reasoning channel (#4596). + * + * The CLI is intentionally launched without tools, so DSML cannot be a usable tool call here. + * Reconstructing it would turn assistant text into execution authority. A marker can be split + * across deltas, therefore the possible prefix tail is withheld until the next delta or terminal. + */ +export class CodeBuddyScaffoldFilter { + private pending = ""; + private failed = false; + + push(chunk: string): CodeBuddyScaffoldFilterResult { + if (this.failed || !chunk) return { text: "", fail: null }; + const buffer = this.pending + chunk; + this.pending = ""; + const lowered = buffer.toLowerCase(); + + let earliest = -1; + let marker = ""; + for (const candidate of MARKERS) { + const at = lowered.indexOf(candidate); + if (at >= 0 && (earliest < 0 || at < earliest)) { + earliest = at; + marker = candidate; + } + } + + if (earliest >= 0) { + this.failed = true; + // With an opener, text before the tag is a completed answer prefix. With only a closer, + // that prefix may be the body of a tag whose opening arrived through another channel/frame. + const text = marker === DSML_OPEN ? buffer.slice(0, earliest) : ""; + return { text, fail: "vendor DSML tool-call markup" }; + } + + const held = heldSuffixLength(buffer); + if (held === 0) return { text: buffer, fail: null }; + this.pending = buffer.slice(buffer.length - held); + return { text: buffer.slice(0, buffer.length - held), fail: null }; + } + + /** Release a suffix proven harmless by the terminal boundary. */ + flush(): CodeBuddyScaffoldFilterResult { + if (this.failed) return { text: "", fail: null }; + const text = this.pending; + this.pending = ""; + return { text, fail: null }; + } +} + +function codeBuddyScaffoldErrorMessage(): string { + return "CodeBuddy CLI emitted vendor tool-call markup in an assistant output channel. This route" + + " runs the CLI with its own tools and MCP servers disabled and Codex owns tool control, so" + + " the turn was refused rather than forwarding or executing vendor agent scaffolding."; +} + +/** Guard both streamed channels without changing the shared coding-agent protocol parser. */ +export function guardCodeBuddyScaffolding(emit: (event: AdapterEvent) => void): (event: AdapterEvent) => void { + const textFilter = new CodeBuddyScaffoldFilter(); + const thinkingFilter = new CodeBuddyScaffoldFilter(); + let closed = false; + + const refuse = (): void => { + if (closed) return; + closed = true; + emit({ + type: "error", + message: codeBuddyScaffoldErrorMessage(), + status: 502, + errorType: "upstream_error", + code: CODEBUDDY_SCAFFOLD_ERROR_CODE, + retryable: false, + }); + }; + + return (event: AdapterEvent): void => { + if (closed) return; + if (event.type === "text_delta" || event.type === "thinking_delta") { + const filter = event.type === "text_delta" ? textFilter : thinkingFilter; + const cleaned = filter.push(event.type === "text_delta" ? event.text : event.thinking); + if (cleaned.text) { + emit(event.type === "text_delta" + ? { ...event, text: cleaned.text } + : { ...event, thinking: cleaned.text }); + } + if (cleaned.fail) refuse(); + return; + } + if (event.type === "done" || event.type === "error" || event.type === "incomplete") { + const textTail = textFilter.flush(); + const thinkingTail = thinkingFilter.flush(); + if (textTail.text) emit({ type: "text_delta", text: textTail.text }); + if (thinkingTail.text) emit({ type: "thinking_delta", thinking: thinkingTail.text }); + closed = true; + emit(event); + return; + } + emit(event); + }; +} diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 5ee17ed807..577a98b808 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -336,6 +336,8 @@ The shared coding-agent projection (CodeBuddy, Qoder) carries tool-result images real image blocks rather than flattening them to the text `[image]`, and orders image blocks chronologically — history before current — so attachment order matches the prose the model reads beside them. Vendor tool execution stays disabled on both -adapters, and Qoder's explicit refusal of original images is unchanged. +adapters. CodeBuddy refuses full-width-bar DSML scaffolding that appears in its text or +reasoning stream instead of promoting vendor text into client execution authority. Qoder's +explicit refusal of original images is unchanged. Canonical Responses identity sanitation and narrowly scoped pre-output combo recovery follow [request-local target compatibility](../runtime.md#request-local-target-compatibility); other adapter contracts remain unchanged. diff --git a/tests/providers/codebuddy-adapter.test.ts b/tests/providers/codebuddy-adapter.test.ts index 0da898ea82..e512819947 100644 --- a/tests/providers/codebuddy-adapter.test.ts +++ b/tests/providers/codebuddy-adapter.test.ts @@ -221,6 +221,65 @@ describe("codebuddy runTurn streams a headless turn", () => { expect(child.written.join("")).toContain('"text":"hello"'); }); + test("refuses full-width DSML tool markup instead of forwarding or executing it", async () => { + const leaked = "I'll inspect it.\n<||DSML|| calls><||DSML|| invoke name=\"functions.exec\">" + + "secret-command"; + const stdout = [ + enc.encode(`${JSON.stringify({ + type: "assistant", + message: { role: "assistant", content: [{ type: "text", text: leaked }] }, + })}\n`), + enc.encode('{"type":"result","subtype":"success","is_error":false}\n'), + ]; + const adapter = createCodeBuddyAdapter(provider(), { + spawn: () => fakeChild(stdout) as unknown as ChildProcess, + which: () => "/usr/bin/codebuddy", + killGraceMs: 20, + }); + + const events = await run(adapter, parsed()); + expect(events.filter(event => event.type === "text_delta")) + .toEqual([{ type: "text_delta", text: "I'll inspect it.\n" }]); + expect(events.some(event => event.type === "done")).toBe(false); + const terminal = events.at(-1); + expect(terminal).toMatchObject({ + type: "error", + code: "vendor_scaffold_detected", + retryable: false, + status: 502, + }); + if (terminal?.type !== "error") throw new Error("expected fail-closed terminal"); + expect(terminal.message).not.toContain("secret-command"); + expect(terminal.message).not.toContain("DSML"); + }); + + test("detects a DSML marker split across streamed text deltas", async () => { + const frame = (text: string) => `${JSON.stringify({ + type: "stream_event", + event: { type: "content_block_delta", delta: { type: "text_delta", text } }, + })}\n`; + const stdout = [ + enc.encode(frame("Safe prefix. <||DS")), + enc.encode(frame("ML|| invoke name=\"functions.exec\">private-body")), + enc.encode('{"type":"result","subtype":"success","is_error":false}\n'), + ]; + const adapter = createCodeBuddyAdapter(provider(), { + spawn: () => fakeChild(stdout) as unknown as ChildProcess, + which: () => "/usr/bin/codebuddy", + killGraceMs: 20, + }); + + const events = await run(adapter, parsed()); + const text = events + .filter(event => event.type === "text_delta") + .map(event => event.type === "text_delta" ? event.text : "") + .join(""); + expect(text).toBe("Safe prefix. "); + expect(text).not.toContain("private-body"); + expect(events.at(-1)).toMatchObject({ type: "error", code: "vendor_scaffold_detected" }); + expect(events.some(event => event.type === "done")).toBe(false); + }); + test("region isolation: the global adapter never spawns with the CN environment", async () => { let seenEnv: NodeJS.ProcessEnv | undefined; const spawn: SpawnFn = (_cmd, _args, opts) => { seenEnv = opts.env as NodeJS.ProcessEnv; return fakeChild([enc.encode('{"type":"result","subtype":"success"}\n')]) as unknown as ChildProcess; }; From 30a595f3d8e91cb4a02f7cfa4568de40f10e37b4 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Tue, 15 Sep 2026 08:16:22 +0000 Subject: [PATCH 2/5] test(codebuddy): preserve guarded channel ordering --- src/adapters/codebuddy/scaffold-guard.ts | 41 +++++++++++++++---- tests/providers/codebuddy-adapter.test.ts | 48 +++++++++++++++++++++++ 2 files changed, 81 insertions(+), 8 deletions(-) diff --git a/src/adapters/codebuddy/scaffold-guard.ts b/src/adapters/codebuddy/scaffold-guard.ts index 43099f9d43..0450e00185 100644 --- a/src/adapters/codebuddy/scaffold-guard.ts +++ b/src/adapters/codebuddy/scaffold-guard.ts @@ -37,6 +37,11 @@ export class CodeBuddyScaffoldFilter { private pending = ""; private failed = false; + /** True while this channel owns a possible split-marker suffix. */ + hasPending(): boolean { + return this.pending.length > 0; + } + push(chunk: string): CodeBuddyScaffoldFilterResult { if (this.failed || !chunk) return { text: "", fail: null }; const buffer = this.pending + chunk; @@ -86,8 +91,27 @@ function codeBuddyScaffoldErrorMessage(): string { export function guardCodeBuddyScaffolding(emit: (event: AdapterEvent) => void): (event: AdapterEvent) => void { const textFilter = new CodeBuddyScaffoldFilter(); const thinkingFilter = new CodeBuddyScaffoldFilter(); + type PendingChannel = "text" | "thinking"; + const pendingOrder: PendingChannel[] = []; let closed = false; + const trackPending = (channel: PendingChannel, filter: CodeBuddyScaffoldFilter): void => { + const at = pendingOrder.indexOf(channel); + if (filter.hasPending()) { + if (at < 0) pendingOrder.push(channel); + } else if (at >= 0) { + pendingOrder.splice(at, 1); + } + }; + + const flushChannel = (channel: PendingChannel): void => { + const tail = channel === "text" ? textFilter.flush() : thinkingFilter.flush(); + if (!tail.text) return; + emit(channel === "text" + ? { type: "text_delta", text: tail.text } + : { type: "thinking_delta", thinking: tail.text }); + }; + const refuse = (): void => { if (closed) return; closed = true; @@ -104,21 +128,22 @@ export function guardCodeBuddyScaffolding(emit: (event: AdapterEvent) => void): return (event: AdapterEvent): void => { if (closed) return; if (event.type === "text_delta" || event.type === "thinking_delta") { - const filter = event.type === "text_delta" ? textFilter : thinkingFilter; + const channel: PendingChannel = event.type === "text_delta" ? "text" : "thinking"; + const filter = channel === "text" ? textFilter : thinkingFilter; const cleaned = filter.push(event.type === "text_delta" ? event.text : event.thinking); + trackPending(channel, filter); if (cleaned.text) { - emit(event.type === "text_delta" - ? { ...event, text: cleaned.text } - : { ...event, thinking: cleaned.text }); + if (event.type === "text_delta") emit({ ...event, text: cleaned.text }); + else emit({ ...event, thinking: cleaned.text }); } if (cleaned.fail) refuse(); return; } if (event.type === "done" || event.type === "error" || event.type === "incomplete") { - const textTail = textFilter.flush(); - const thinkingTail = thinkingFilter.flush(); - if (textTail.text) emit({ type: "text_delta", text: textTail.text }); - if (thinkingTail.text) emit({ type: "thinking_delta", thinking: thinkingTail.text }); + // Both filters can hold a possible split marker at once. Flush by the order in which + // those tails arrived; a fixed text-first flush changes the provider event sequence. + for (const channel of pendingOrder) flushChannel(channel); + pendingOrder.length = 0; closed = true; emit(event); return; diff --git a/tests/providers/codebuddy-adapter.test.ts b/tests/providers/codebuddy-adapter.test.ts index e512819947..c882f4b2e7 100644 --- a/tests/providers/codebuddy-adapter.test.ts +++ b/tests/providers/codebuddy-adapter.test.ts @@ -3,6 +3,7 @@ import { EventEmitter } from "node:events"; import { Readable, Writable } from "node:stream"; import type { ChildProcess } from "node:child_process"; import { buildArgs, buildChildEnv, createCodeBuddyAdapter, type SpawnFn } from "../../src/adapters/codebuddy/adapter"; +import { guardCodeBuddyScaffolding } from "../../src/adapters/codebuddy/scaffold-guard"; import { CODEBUDDY_CN_PROFILE, CODEBUDDY_GLOBAL_PROFILE, clearCodeBuddyBinaryCache } from "../../src/adapters/codebuddy/profiles"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; import { createTestTranslatorBudget } from "../helpers/translator-budget"; @@ -280,6 +281,53 @@ describe("codebuddy runTurn streams a headless turn", () => { expect(events.some(event => event.type === "done")).toBe(false); }); + test("refuses DSML tool markup from the reasoning channel", async () => { + const stdout = [ + enc.encode(`${JSON.stringify({ + type: "stream_event", + event: { + type: "content_block_delta", + delta: { + type: "thinking_delta", + thinking: "Safe thought. <||DSML|| invoke name=\"functions.exec\">private-body", + }, + }, + })}\n`), + enc.encode('{"type":"result","subtype":"success","is_error":false}\n'), + ]; + const adapter = createCodeBuddyAdapter(provider(), { + spawn: () => fakeChild(stdout) as unknown as ChildProcess, + which: () => "/usr/bin/codebuddy", + killGraceMs: 20, + }); + + const events = await run(adapter, parsed()); + expect(events.filter(event => event.type === "thinking_delta")) + .toEqual([{ type: "thinking_delta", thinking: "Safe thought. " }]); + expect(events.some(event => event.type === "done")).toBe(false); + expect(events.at(-1)).toMatchObject({ + type: "error", + code: "vendor_scaffold_detected", + retryable: false, + }); + expect(JSON.stringify(events)).not.toContain("private-body"); + }); + + test("flushes harmless text and reasoning tails in their arrival order", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + + guarded({ type: "thinking_delta", thinking: "<" }); + guarded({ type: "text_delta", text: "<" }); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "thinking_delta", thinking: "<" }, + { type: "text_delta", text: "<" }, + { type: "done", stopReason: "stop" }, + ]); + }); + test("region isolation: the global adapter never spawns with the CN environment", async () => { let seenEnv: NodeJS.ProcessEnv | undefined; const spawn: SpawnFn = (_cmd, _args, opts) => { seenEnv = opts.env as NodeJS.ProcessEnv; return fakeChild([enc.encode('{"type":"result","subtype":"success"}\n')]) as unknown as ChildProcess; }; From 49333b4074c5ba27e0ebba612d93679ea94f8b08 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Tue, 15 Sep 2026 08:53:59 +0000 Subject: [PATCH 3/5] fix(codebuddy): preserve replaced tail ordering --- src/adapters/codebuddy/scaffold-guard.ts | 15 +++++++++++++-- tests/providers/codebuddy-adapter.test.ts | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/adapters/codebuddy/scaffold-guard.ts b/src/adapters/codebuddy/scaffold-guard.ts index 0450e00185..3d67321af3 100644 --- a/src/adapters/codebuddy/scaffold-guard.ts +++ b/src/adapters/codebuddy/scaffold-guard.ts @@ -95,10 +95,20 @@ export function guardCodeBuddyScaffolding(emit: (event: AdapterEvent) => void): const pendingOrder: PendingChannel[] = []; let closed = false; - const trackPending = (channel: PendingChannel, filter: CodeBuddyScaffoldFilter): void => { + const trackPending = ( + channel: PendingChannel, + filter: CodeBuddyScaffoldFilter, + replacedPending: boolean, + ): void => { const at = pendingOrder.indexOf(channel); if (filter.hasPending()) { if (at < 0) pendingOrder.push(channel); + else if (replacedPending) { + // push() consumed the old suffix before withholding a new one. The new tail arrived after + // every other channel already in the queue, so keeping the old index would reorder output. + pendingOrder.splice(at, 1); + pendingOrder.push(channel); + } } else if (at >= 0) { pendingOrder.splice(at, 1); } @@ -130,8 +140,9 @@ export function guardCodeBuddyScaffolding(emit: (event: AdapterEvent) => void): if (event.type === "text_delta" || event.type === "thinking_delta") { const channel: PendingChannel = event.type === "text_delta" ? "text" : "thinking"; const filter = channel === "text" ? textFilter : thinkingFilter; + const replacedPending = filter.hasPending(); const cleaned = filter.push(event.type === "text_delta" ? event.text : event.thinking); - trackPending(channel, filter); + trackPending(channel, filter, replacedPending); if (cleaned.text) { if (event.type === "text_delta") emit({ ...event, text: cleaned.text }); else emit({ ...event, thinking: cleaned.text }); diff --git a/tests/providers/codebuddy-adapter.test.ts b/tests/providers/codebuddy-adapter.test.ts index c882f4b2e7..fc01c52867 100644 --- a/tests/providers/codebuddy-adapter.test.ts +++ b/tests/providers/codebuddy-adapter.test.ts @@ -328,6 +328,25 @@ describe("codebuddy runTurn streams a headless turn", () => { ]); }); + test("moves a replaced pending tail to its new arrival position", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + + guarded({ type: "thinking_delta", thinking: "<" }); + guarded({ type: "text_delta", text: "<" }); + // The old thinking tail is consumed into harmless output and a NEW possible marker tail is + // withheld. That new tail arrived after the text tail and must therefore flush after it. + guarded({ type: "thinking_delta", thinking: "safe<" }); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "thinking_delta", thinking: " { let seenEnv: NodeJS.ProcessEnv | undefined; const spawn: SpawnFn = (_cmd, _args, opts) => { seenEnv = opts.env as NodeJS.ProcessEnv; return fakeChild([enc.encode('{"type":"result","subtype":"success"}\n')]) as unknown as ChildProcess; }; From 26f4e6eba664b71f475ad865653563415af3f4e1 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Tue, 15 Sep 2026 09:15:12 +0000 Subject: [PATCH 4/5] fix(codebuddy): preserve cross-channel event order --- src/adapters/codebuddy/scaffold-guard.ts | 129 +++++++++++++++------- tests/providers/codebuddy-adapter.test.ts | 36 +++++- 2 files changed, 124 insertions(+), 41 deletions(-) diff --git a/src/adapters/codebuddy/scaffold-guard.ts b/src/adapters/codebuddy/scaffold-guard.ts index 3d67321af3..826756efdb 100644 --- a/src/adapters/codebuddy/scaffold-guard.ts +++ b/src/adapters/codebuddy/scaffold-guard.ts @@ -12,7 +12,12 @@ const MARKERS = [DSML_OPEN, DSML_CLOSE] as const; const MAX_MARKER_LENGTH = Math.max(...MARKERS.map(marker => marker.length)); export interface CodeBuddyScaffoldFilterResult { + /** Bytes released from the suffix withheld by an earlier event on this channel. */ + releasedPending: string; + /** Safe bytes that belong to the event currently being processed. */ text: string; + /** The earlier pending slot still owns the newly extended marker prefix. */ + pendingContinues: boolean; fail: string | null; } @@ -43,8 +48,11 @@ export class CodeBuddyScaffoldFilter { } push(chunk: string): CodeBuddyScaffoldFilterResult { - if (this.failed || !chunk) return { text: "", fail: null }; - const buffer = this.pending + chunk; + if (this.failed || !chunk) { + return { releasedPending: "", text: "", pendingContinues: false, fail: null }; + } + const priorPending = this.pending; + const buffer = priorPending + chunk; this.pending = ""; const lowered = buffer.toLowerCase(); @@ -62,22 +70,40 @@ export class CodeBuddyScaffoldFilter { this.failed = true; // With an opener, text before the tag is a completed answer prefix. With only a closer, // that prefix may be the body of a tag whose opening arrived through another channel/frame. - const text = marker === DSML_OPEN ? buffer.slice(0, earliest) : ""; - return { text, fail: "vendor DSML tool-call markup" }; + const safe = marker === DSML_OPEN ? buffer.slice(0, earliest) : ""; + const releasedLength = Math.min(priorPending.length, safe.length); + return { + releasedPending: safe.slice(0, releasedLength), + text: safe.slice(releasedLength), + pendingContinues: false, + fail: "vendor DSML tool-call markup", + }; } const held = heldSuffixLength(buffer); - if (held === 0) return { text: buffer, fail: null }; - this.pending = buffer.slice(buffer.length - held); - return { text: buffer.slice(0, buffer.length - held), fail: null }; + const safe = held === 0 ? buffer : buffer.slice(0, buffer.length - held); + if (held > 0) this.pending = buffer.slice(buffer.length - held); + const releasedLength = Math.min(priorPending.length, safe.length); + return { + releasedPending: safe.slice(0, releasedLength), + text: safe.slice(releasedLength), + // A marker prefix extended without releasing any byte still belongs at the earlier event's + // position. Once any prior byte is released, a newly held suffix belongs to this event. + pendingContinues: priorPending.length > 0 + && safe.length === 0 + && this.pending.startsWith(priorPending), + fail: null, + }; } /** Release a suffix proven harmless by the terminal boundary. */ flush(): CodeBuddyScaffoldFilterResult { - if (this.failed) return { text: "", fail: null }; + if (this.failed) { + return { releasedPending: "", text: "", pendingContinues: false, fail: null }; + } const text = this.pending; this.pending = ""; - return { text, fail: null }; + return { releasedPending: text, text: "", pendingContinues: false, fail: null }; } } @@ -92,38 +118,58 @@ export function guardCodeBuddyScaffolding(emit: (event: AdapterEvent) => void): const textFilter = new CodeBuddyScaffoldFilter(); const thinkingFilter = new CodeBuddyScaffoldFilter(); type PendingChannel = "text" | "thinking"; - const pendingOrder: PendingChannel[] = []; + type EventSlot = { resolved: boolean; event?: AdapterEvent }; + const eventQueue: EventSlot[] = []; + const pendingSlots = new Map(); let closed = false; - const trackPending = ( - channel: PendingChannel, - filter: CodeBuddyScaffoldFilter, - replacedPending: boolean, - ): void => { - const at = pendingOrder.indexOf(channel); - if (filter.hasPending()) { - if (at < 0) pendingOrder.push(channel); - else if (replacedPending) { - // push() consumed the old suffix before withholding a new one. The new tail arrived after - // every other channel already in the queue, so keeping the old index would reorder output. - pendingOrder.splice(at, 1); - pendingOrder.push(channel); - } - } else if (at >= 0) { - pendingOrder.splice(at, 1); + const channelEvent = (channel: PendingChannel, text: string): AdapterEvent => ( + channel === "text" + ? { type: "text_delta", text } + : { type: "thinking_delta", thinking: text } + ); + + const drainResolved = (): void => { + while (eventQueue[0]?.resolved) { + const slot = eventQueue.shift()!; + if (slot.event) emit(slot.event); } }; - const flushChannel = (channel: PendingChannel): void => { - const tail = channel === "text" ? textFilter.flush() : thinkingFilter.flush(); - if (!tail.text) return; - emit(channel === "text" - ? { type: "text_delta", text: tail.text } - : { type: "thinking_delta", thinking: tail.text }); + const enqueueResolved = (event: AdapterEvent): void => { + eventQueue.push({ resolved: true, event }); + drainResolved(); + }; + + const resolvePendingSlot = (channel: PendingChannel, text: string): void => { + const slot = pendingSlots.get(channel); + if (!slot) return; + slot.resolved = true; + if (text) slot.event = channelEvent(channel, text); + pendingSlots.delete(channel); + drainResolved(); + }; + + const enqueuePendingSlot = (channel: PendingChannel): void => { + const slot: EventSlot = { resolved: false }; + eventQueue.push(slot); + pendingSlots.set(channel, slot); + }; + + const flushAllPending = (): void => { + for (const channel of ["text", "thinking"] as const) { + if (!pendingSlots.has(channel)) continue; + const filter = channel === "text" ? textFilter : thinkingFilter; + resolvePendingSlot(channel, filter.flush().releasedPending); + } + drainResolved(); }; const refuse = (): void => { if (closed) return; + // A terminal refusal proves every other marker-like suffix harmless. Resolve queued slots by + // their original positions before the error so no later safe event overtakes an older tail. + flushAllPending(); closed = true; emit({ type: "error", @@ -140,21 +186,24 @@ export function guardCodeBuddyScaffolding(emit: (event: AdapterEvent) => void): if (event.type === "text_delta" || event.type === "thinking_delta") { const channel: PendingChannel = event.type === "text_delta" ? "text" : "thinking"; const filter = channel === "text" ? textFilter : thinkingFilter; - const replacedPending = filter.hasPending(); + const hadPending = filter.hasPending(); const cleaned = filter.push(event.type === "text_delta" ? event.text : event.thinking); - trackPending(channel, filter, replacedPending); + if (hadPending && !cleaned.pendingContinues) { + resolvePendingSlot(channel, cleaned.releasedPending); + } if (cleaned.text) { - if (event.type === "text_delta") emit({ ...event, text: cleaned.text }); - else emit({ ...event, thinking: cleaned.text }); + enqueueResolved(event.type === "text_delta" + ? { ...event, text: cleaned.text } + : { ...event, thinking: cleaned.text }); + } + if (filter.hasPending() && !cleaned.pendingContinues) { + enqueuePendingSlot(channel); } if (cleaned.fail) refuse(); return; } if (event.type === "done" || event.type === "error" || event.type === "incomplete") { - // Both filters can hold a possible split marker at once. Flush by the order in which - // those tails arrived; a fixed text-first flush changes the provider event sequence. - for (const channel of pendingOrder) flushChannel(channel); - pendingOrder.length = 0; + flushAllPending(); closed = true; emit(event); return; diff --git a/tests/providers/codebuddy-adapter.test.ts b/tests/providers/codebuddy-adapter.test.ts index fc01c52867..27336b8cd4 100644 --- a/tests/providers/codebuddy-adapter.test.ts +++ b/tests/providers/codebuddy-adapter.test.ts @@ -340,13 +340,47 @@ describe("codebuddy runTurn streams a headless turn", () => { guarded({ type: "done", stopReason: "stop" }); expect(events).toEqual([ - { type: "thinking_delta", thinking: " { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + + guarded({ type: "thinking_delta", thinking: "<" }); + guarded({ type: "text_delta", text: "Hello" }); + expect(events).toEqual([]); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "thinking_delta", thinking: "<" }, + { type: "text_delta", text: "Hello" }, + { type: "done", stopReason: "stop" }, + ]); + }); + + test("releases a continued pending tail at its first position without moving later bytes", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + + guarded({ type: "thinking_delta", thinking: "<|" }); + guarded({ type: "text_delta", text: "middle" }); + guarded({ type: "thinking_delta", thinking: "safe" }); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "thinking_delta", thinking: "<|" }, + { type: "text_delta", text: "middle" }, + { type: "thinking_delta", thinking: "safe" }, + { type: "done", stopReason: "stop" }, + ]); + }); + test("region isolation: the global adapter never spawns with the CN environment", async () => { let seenEnv: NodeJS.ProcessEnv | undefined; const spawn: SpawnFn = (_cmd, _args, opts) => { seenEnv = opts.env as NodeJS.ProcessEnv; return fakeChild([enc.encode('{"type":"result","subtype":"success"}\n')]) as unknown as ChildProcess; }; From dd8d1c77ec9067704f9bd2301f1b5c8a691dcc49 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Tue, 15 Sep 2026 09:31:07 +0000 Subject: [PATCH 5/5] fix(codebuddy): retain pending slots across empty deltas Co-authored-by: Ingwannu --- src/adapters/codebuddy/scaffold-guard.ts | 13 ++++++++++++- tests/providers/codebuddy-adapter.test.ts | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/adapters/codebuddy/scaffold-guard.ts b/src/adapters/codebuddy/scaffold-guard.ts index 826756efdb..0fc90d5c69 100644 --- a/src/adapters/codebuddy/scaffold-guard.ts +++ b/src/adapters/codebuddy/scaffold-guard.ts @@ -48,9 +48,20 @@ export class CodeBuddyScaffoldFilter { } push(chunk: string): CodeBuddyScaffoldFilterResult { - if (this.failed || !chunk) { + if (this.failed) { return { releasedPending: "", text: "", pendingContinues: false, fail: null }; } + // Empty deltas carry no new ordering information. If this channel already owns a possible + // marker suffix, keep that original event slot unresolved instead of replacing it after later + // events in another channel. + if (!chunk) { + return { + releasedPending: "", + text: "", + pendingContinues: this.hasPending(), + fail: null, + }; + } const priorPending = this.pending; const buffer = priorPending + chunk; this.pending = ""; diff --git a/tests/providers/codebuddy-adapter.test.ts b/tests/providers/codebuddy-adapter.test.ts index 27336b8cd4..a6a574932d 100644 --- a/tests/providers/codebuddy-adapter.test.ts +++ b/tests/providers/codebuddy-adapter.test.ts @@ -364,6 +364,22 @@ describe("codebuddy runTurn streams a headless turn", () => { ]); }); + test("keeps an existing pending slot when its channel receives an empty delta", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + + guarded({ type: "thinking_delta", thinking: "<" }); + guarded({ type: "text_delta", text: "Hello" }); + guarded({ type: "thinking_delta", thinking: "" }); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "thinking_delta", thinking: "<" }, + { type: "text_delta", text: "Hello" }, + { type: "done", stopReason: "stop" }, + ]); + }); + test("releases a continued pending tail at its first position without moving later bytes", () => { const events: AdapterEvent[] = []; const guarded = guardCodeBuddyScaffolding(event => events.push(event));