From 915af60e52d12de0bddd62037671d96983e83345 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 16 Sep 2026 11:35:26 +0900 Subject: [PATCH] fix(codebuddy): refuse leaked vendor tool-call scaffolding (#4596) The CodeBuddy route launches the vendor CLI with --tools "" and --strict-mcp-config, so the routed model has no native tool channel and writes its call as prose. The shared coding-agent projection forwards text_delta unrepaired, so that markup reached the client as an ordinary assistant answer. Qoder's guard does not match it. The leaked tags are wrapped in FULLWIDTH VERTICAL LINE (U+FF5C), which none of the shipped UNREPAIRABLE_MARKERS cover, so this needed a signature of its own rather than a port. Refusal requires the observed two-line grammar: a calls control line at column zero, outside a Markdown fence, immediately followed by an invoke line naming a functions.* tool. A lone tag, a quoted or inline-code literal, a fenced example, a blockquote, indented source, or prose discussing the markup all carry extra syntax before the tag and are forwarded untouched. Matching the marker alone would refuse a legitimate answer that merely explains this protocol, which is why the detector is narrower than the marker spelling. A detected leak preserves the answer text already proven safe, emits one non-retryable vendor_scaffold_detected error, and suppresses the vendor's later success terminal so the client never sees a completed turn. Markers split across streamed deltas are caught by holding only a bounded suffix that could still complete a control sequence or a fence; unrelated pending text is released at the next mismatch or terminal. The reasoning channel is guarded independently. Leaked prose is never promoted into a real tool call. The text channel carries no authenticated call envelope and no validated arguments, so converting it would manufacture execution authority out of model output. Kept CodeBuddy-owned rather than lifted into the shared coding-agent path, the same containment #4234 chose for Qoder: the contract observed here is this vendor's, and #4190's lane packet asked for a report rather than symmetry. Co-authored-by: Ingwannu --- .../src/content/docs/guides/providers.md | 2 +- src/adapters/codebuddy/adapter.ts | 3 +- src/adapters/codebuddy/scaffold-guard.ts | 248 ++++++++++++++++++ structure/providers/chat-compat.md | 6 +- tests/providers/codebuddy-adapter.test.ts | 231 ++++++++++++++++ 5 files changed, 487 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 f45f653c395..236ea50fa94 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 an unquoted DSML `calls` control line followed by a `functions.*` invoke control line into text or reasoning, OpenCodex refuses the turn instead of forwarding the scaffold or interpreting it as an executable call. DSML discussed or quoted in prose, inline code, fenced code, or source examples remains ordinary answer text. - **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 234e06907e3..a769ac37dae 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 00000000000..6f8c80aed57 --- /dev/null +++ b/src/adapters/codebuddy/scaffold-guard.ts @@ -0,0 +1,248 @@ +import type { AdapterEvent } from "../../types"; + +/** Error code for a CodeBuddy turn whose output contains vendor agent scaffolding. */ +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 +// discussing one tag. +const DSML_CALLS_LINE = "<||dsml|| calls>"; +const DSML_INVOKE_PREFIX = "<||dsml|| invoke name=\"functions."; + +export interface CodeBuddyScaffoldFilterResult { + /** Bytes released from a suffix withheld by an earlier event on this channel. */ + releasedPending: string; + /** Safe bytes belonging to the event currently being processed. */ + text: string; + /** The earlier pending event still owns the extended candidate. */ + pendingContinues: boolean; + fail: boolean; +} + +interface ScanResult { + safe: string; + held: string; + fail: boolean; + fence: "`" | "~" | null; + lineStart: boolean; +} + +function prefixAtEnd(text: string, at: number, expected: string): boolean { + const rest = text.slice(at).toLowerCase(); + return rest.length < expected.length && expected.startsWith(rest); +} + +/** + * Scan complete bytes and retain only a bounded suffix that can still become a control sequence. + * + * 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. + */ +function scan( + text: string, + initialFence: "`" | "~" | null, + initialLineStart: boolean, +): ScanResult { + let fence = initialFence; + let lineStart = initialLineStart; + let index = 0; + + while (index < text.length) { + if (lineStart) { + const fenceMarkers = fence ? [fence.repeat(3)] : ["```", "~~~"]; + const completeFence = fenceMarkers.find(marker => text.startsWith(marker, index)); + if (completeFence) { + fence = fence ? null : (completeFence[0] as "`" | "~"); + index += completeFence.length; + lineStart = false; + continue; + } + if (fenceMarkers.some(marker => prefixAtEnd(text, index, marker))) { + return { safe: text.slice(0, index), held: text.slice(index), fail: false, fence, lineStart }; + } + + if (!fence) { + const lowered = text.slice(index).toLowerCase(); + if (lowered.startsWith(DSML_CALLS_LINE)) { + const afterCalls = index + DSML_CALLS_LINE.length; + let invokeAt = -1; + if (text[afterCalls] === "\n") invokeAt = afterCalls + 1; + else if (text[afterCalls] === "\r" && text[afterCalls + 1] === "\n") invokeAt = afterCalls + 2; + else if (afterCalls === text.length || (text[afterCalls] === "\r" && afterCalls + 1 === text.length)) { + return { safe: text.slice(0, index), held: text.slice(index), fail: false, fence, lineStart }; + } + + if (invokeAt >= 0) { + const invokeRest = text.slice(invokeAt).toLowerCase(); + if (invokeRest.startsWith(DSML_INVOKE_PREFIX)) { + return { safe: text.slice(0, index), held: "", fail: true, fence, lineStart }; + } + if (invokeRest.length === 0 || DSML_INVOKE_PREFIX.startsWith(invokeRest)) { + return { safe: text.slice(0, index), held: text.slice(index), fail: false, fence, lineStart }; + } + } + } else if (prefixAtEnd(text, index, DSML_CALLS_LINE)) { + return { safe: text.slice(0, index), held: text.slice(index), fail: false, fence, lineStart }; + } + } + } + + const char = text[index]!; + index += 1; + lineStart = char === "\n"; + } + + return { safe: text, held: "", fail: false, fence, lineStart }; +} + +/** Streaming DSML control-sequence filter for one text or reasoning channel. */ +export class CodeBuddyScaffoldFilter { + private pending = ""; + private failed = false; + private fence: "`" | "~" | null = null; + private lineStart = true; + + /** True while an earlier event owns an unresolved marker or fence prefix. */ + hasPending(): boolean { + return this.pending.length > 0; + } + + push(chunk: string): CodeBuddyScaffoldFilterResult { + if (this.failed) { + return { releasedPending: "", text: "", pendingContinues: false, fail: false }; + } + if (!chunk) { + return { + releasedPending: "", + text: "", + pendingContinues: this.hasPending(), + fail: false, + }; + } + + const priorPending = this.pending; + const result = scan(priorPending + chunk, this.fence, this.lineStart); + this.pending = result.held; + this.fence = result.fence; + this.lineStart = result.lineStart; + this.failed = result.fail; + + const releasedLength = Math.min(priorPending.length, result.safe.length); + return { + releasedPending: result.safe.slice(0, releasedLength), + text: result.safe.slice(releasedLength), + pendingContinues: priorPending.length > 0 && result.safe.length === 0 && result.held.length > 0, + fail: result.fail, + }; + } + + /** Release a suffix that never completed the two-line control grammar. */ + flush(): CodeBuddyScaffoldFilterResult { + if (this.failed) { + return { releasedPending: "", text: "", pendingContinues: false, fail: false }; + } + const text = this.pending; + this.pending = ""; + return { releasedPending: text, text: "", pendingContinues: false, fail: false }; + } +} + +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 while preserving event order around withheld marker prefixes. */ +export function guardCodeBuddyScaffolding(emit: (event: AdapterEvent) => void): (event: AdapterEvent) => void { + const textFilter = new CodeBuddyScaffoldFilter(); + const thinkingFilter = new CodeBuddyScaffoldFilter(); + type PendingChannel = "text" | "thinking"; + type EventSlot = { resolved: boolean; event?: AdapterEvent }; + const eventQueue: EventSlot[] = []; + const pendingSlots = new Map(); + let closed = false; + + 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 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; + flushAllPending(); + 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 channel: PendingChannel = event.type === "text_delta" ? "text" : "thinking"; + const filter = channel === "text" ? textFilter : thinkingFilter; + const hadPending = filter.hasPending(); + const cleaned = filter.push(event.type === "text_delta" ? event.text : event.thinking); + if (hadPending && !cleaned.pendingContinues) resolvePendingSlot(channel, cleaned.releasedPending); + if (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") { + flushAllPending(); + closed = true; + emit(event); + return; + } + enqueueResolved(event); + }; +} diff --git a/structure/providers/chat-compat.md b/structure/providers/chat-compat.md index 92b19a97a45..270641484da 100644 --- a/structure/providers/chat-compat.md +++ b/structure/providers/chat-compat.md @@ -354,6 +354,10 @@ 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 an unquoted, line-oriented full-width-bar DSML `calls` +container followed by a `functions.*` 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. 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 0da898ea829..76caabfae99 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"; @@ -221,6 +222,236 @@ describe("codebuddy runTurn streams a headless turn", () => { expect(child.written.join("")).toContain('"text":"hello"'); }); + test("refuses a full-message DSML calls-and-invoke scaffold", async () => { + const leaked = "I'll inspect it.\n<||DSML|| calls>\n" + + "<||DSML|| invoke name=\"functions.exec\">\nsecret-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); + expect(events.at(-1)).toMatchObject({ + type: "error", + code: "vendor_scaffold_detected", + retryable: false, + status: 502, + }); + expect(JSON.stringify(events)).not.toContain("secret-command"); + }); + + test("detects a DSML control sequence 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.\n<||DS")), + enc.encode(frame("ML|| calls>\n<||DSML|| invoke name=\"funct")), + enc.encode(frame("ions.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()); + expect(events.filter(event => event.type === "text_delta")) + .toEqual([{ type: "text_delta", text: "Safe prefix.\n" }]); + expect(events.at(-1)).toMatchObject({ type: "error", code: "vendor_scaffold_detected" }); + expect(events.some(event => event.type === "done")).toBe(false); + expect(JSON.stringify(events)).not.toContain("private-body"); + }); + + test("refuses DSML calls-and-invoke scaffolding from reasoning independently", async () => { + const stdout = [ + enc.encode(`${JSON.stringify({ + type: "stream_event", + event: { + type: "content_block_delta", + delta: { + type: "thinking_delta", + thinking: "Safe thought.\n<||DSML|| calls>\n" + + "<||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.\n" }]); + expect(events.at(-1)).toMatchObject({ + type: "error", + code: "vendor_scaffold_detected", + retryable: false, + }); + expect(events.some(event => event.type === "done")).toBe(false); + expect(JSON.stringify(events)).not.toContain("private-body"); + }); + + test("delivers a lone discussed DSML calls tag unchanged", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + const answer = "The string <||DSML|| calls> names the calls container."; + + guarded({ type: "text_delta", text: answer }); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "text_delta", text: answer }, + { type: "done", stopReason: "stop" }, + ]); + }); + + test("delivers quoted and inline-code DSML literals unchanged", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + const answer = "\"<||DSML|| calls>\"\n" + + "\"<||DSML|| invoke name=\\\"functions.exec\\\">\"\n" + + "Use `<||DSML|| calls>` when discussing the literal.\n" + + "> <||DSML|| calls>\n> <||DSML|| invoke name=\"functions.exec\">"; + + guarded({ type: "text_delta", text: answer }); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "text_delta", text: answer }, + { type: "done", stopReason: "stop" }, + ]); + }); + + test("delivers a fenced DSML source example unchanged across deltas", () => { + 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```"; + + guarded({ type: "text_delta", text: first }); + guarded({ type: "text_delta", text: second }); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "text_delta", text: first }, + { type: "text_delta", text: second }, + { type: "done", stopReason: "stop" }, + ]); + }); + + test("delivers source strings containing both DSML literals unchanged", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + const answer = "const calls = '<||DSML|| calls>';\n" + + "const invoke = '<||DSML|| invoke name=\"functions.exec\">';"; + + guarded({ type: "text_delta", text: answer }); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "text_delta", text: answer }, + { type: "done", stopReason: "stop" }, + ]); + }); + + test("delivers an unquoted invoke line when no calls container precedes it", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + const answer = "<||DSML|| invoke name=\"functions.exec\">"; + + guarded({ type: "text_delta", text: answer }); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "text_delta", text: answer }, + { type: "done", stopReason: "stop" }, + ]); + }); + + test("releases a lone control-line candidate at the terminal", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + + guarded({ type: "text_delta", text: "<||DSML|| calls>" }); + expect(events).toEqual([]); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "text_delta", text: "<||DSML|| calls>" }, + { type: "done", stopReason: "stop" }, + ]); + }); + + test("queues later events behind an unresolved marker prefix", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + + guarded({ type: "thinking_delta", thinking: "<" }); + guarded({ type: "text_delta", text: "Hello" }); + guarded({ type: "tool_call_start", id: "call_1", name: "exec" }); + expect(events).toEqual([]); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "thinking_delta", thinking: "<" }, + { type: "text_delta", text: "Hello" }, + { type: "tool_call_start", id: "call_1", name: "exec" }, + { type: "done", stopReason: "stop" }, + ]); + }); + + 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("moves a replaced pending marker prefix 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: "<" }); + guarded({ type: "thinking_delta", thinking: "not marker\n<" }); + guarded({ type: "done", stopReason: "stop" }); + + expect(events).toEqual([ + { type: "thinking_delta", thinking: "<" }, + { type: "text_delta", text: "<" }, + { type: "thinking_delta", thinking: "not marker\n" }, + { type: "thinking_delta", thinking: "<" }, + { 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; };