diff --git a/devlog/_plan/260911_l6_streaming_tools/020_4190_qoder_scaffold_guard.md b/devlog/_plan/260911_l6_streaming_tools/020_4190_qoder_scaffold_guard.md
new file mode 100644
index 0000000000..f24b36fd9b
--- /dev/null
+++ b/devlog/_plan/260911_l6_streaming_tools/020_4190_qoder_scaffold_guard.md
@@ -0,0 +1,97 @@
+# 020 — #4190: refuse vendor scaffolding on the qoder route
+
+Unit: `devlog/_plan/260911_l6_streaming_tools`. Lane L6, work-phase 2.
+Issue: [#4190](https://github.com/lidge-jun/opencodex/issues/4190).
+
+## The contract that was being broken
+
+The qoder route is documented as a text and reasoning surface. `buildQoderArgs` launches the
+CLI with `--tools "" --strict-mcp-config --setting-sources "" --max-turns 1
+--no-session-persistence`, and both `coding-agent/protocol.ts` and `coding-agent/turn.ts`
+state that Codex retains tool control and vendor tools are never invoked.
+
+The reporter saw two things reach the client as assistant text anyway: an MCP lazy-loading
+`` block enumerating the local machine's configured MCP servers from
+`~/.qoder/mcp.json` and from plugins, and vendor tool-call markup opened as
+`` and closed as `` — mismatched, which is what a model emitting
+remembered markup looks like rather than a serializer's output.
+
+The proxy-side hole is one line of trust. `mapRawStreamEvent` forwards a `text_delta`
+verbatim; frame *types* are filtered, frame *contents* are not. So whatever the vendor's agent
+layer puts in the text channel is relayed, and the strongest version of this leak publishes
+the operator's MCP server inventory to whoever is reading the turn.
+
+## What was implemented
+
+`src/adapters/qoder/scaffold-guard.ts`, a streaming filter, and `guardQoderScaffolding` in
+the adapter, which wraps the `emit` callback handed to `runCodingAgentTurn`. That wrapper is
+the last qoder-specific point in the path, which is why the guard sits there rather than in
+the parser every coding-agent CLI shares — CodeBuddy runs the same turn code and is not part
+of this report.
+
+Two shapes, two answers, per the lane packet's recorded decision:
+
+- A complete `…` block is recognizable and
+ self-delimiting. It is removed and the answer around it survives.
+- Anything else carrying a scaffolding signature — ``, or a `` with no opener — fails the turn closed. A partial
+ tool-call block has no reliable end, and the prose around it may be the vendor's own agent
+ narration rather than the model's answer, so repairing it would be guesswork.
+
+It is a stream filter, not a regex over a finished string. A marker can be split across
+deltas, so a tail that is still a possible marker prefix is held rather than emitted, and the
+terminal event flushes both channels first. That flush is load-bearing in a way that is not
+obvious: `isContentEvent` in `empty-completion-guard.ts` counts only non-empty
+`text_delta`s as content, so an answer swallowed in full and followed by `done` would reach
+the client as a successful but empty turn instead of as the refusal it is.
+
+The suppressed block is discarded as it arrives; only the trailing bytes needed to spot a
+split closer are kept, so an unclosed reminder cannot grow memory. A 64 KiB ceiling bounds
+how much of a turn one unterminated block may swallow before the turn is refused.
+
+## Decisions this issue left open
+
+**Qoder only, not the shared coding-agent path.** The lane owns `src/adapters/qoder/` and
+not `src/adapters/coding-agent/protocol.ts`, and the leak is reported only for Qoder. If
+CodeBuddy turns out to do the same thing, the filter is a pure module and lifting it is a
+small change — but it should be driven by a report, not by symmetry.
+
+**Fail closed rather than strip, for tool-call markup.** The issue's own review lists both
+options. Silently deleting markup leaves the user with a mutilated answer and no signal that
+the route's contract was violated; the error names the marker class and says why.
+
+**Not retryable.** The leak is intermittent, so a retry would often succeed. It is still
+marked `retryable: false`: an automatic retry spends the operator's vendor credits on a
+contract violation the proxy cannot influence, and hiding an intermittent violation is how it
+stays unfixed.
+
+**Vendor tool-call frames are left alone.** `mapRawStreamEvent` maps a `tool_use` block to
+`tool_call_start`. That is arguably also a contract violation, but it is a typed frame rather
+than leaked text, the issue reports the text channel, and `protocol.ts` documents that seam
+as deliberately prepared for a future tool bridge.
+
+**Known false positive.** A turn that legitimately discusses `` or
+`` syntax will be stripped or refused. That is the cost of failing closed on a
+route whose leak publishes the operator's MCP inventory, and it is the direction the packet
+recorded.
+
+**Not touched: making the CLI actually run with MCP disabled.** The issue's first suggested
+direction is to fix the spawn so the vendor agent layer never initializes. The installed
+`@qoder-ai/qodercli` bundle still contains the reminder builder and appears to initialize it
+despite the flags, so that fix lives in the vendor, not here. This guard is the containment
+that does not depend on the vendor agreeing.
+
+## Verification
+
+Focused regression test at `tests/providers/qoder-scaffold-guard.test.ts`, beside the
+existing `qoder-adapter.test.ts`, registered in `scripts/test-layout/layout.json` and
+`tests/fixtures/test-layout-expected.json`. It covers block removal, a marker split across
+three deltas, the held tail released on flush, fail-closed on tool-call markup and on a stray
+closer, the unterminated-block case, the latch, and the wrapper's terminal handling including
+the flush-before-`done` rule and forwarding a vendor error rather than replacing it. Two
+cases assert that neither the leaked server list nor the leaked shell command appears in the
+refusal message.
+
+Local suite, typecheck and build: NOT RUN, by operator instruction for this dispatch round.
+Hosted CI on the pushed head is the evidence.
+
diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json
index cee1514e55..4056d8bc26 100644
--- a/scripts/test-layout/layout.json
+++ b/scripts/test-layout/layout.json
@@ -997,6 +997,7 @@
"proxy-liveness.test.ts": "server",
"qoder-adapter.test.ts": "providers",
"qoder-live-models.test.ts": "providers",
+ "qoder-scaffold-guard.test.ts": "providers",
"quota-401-recovery-runtime.test.ts": "usage",
"quota-401-recovery.test.ts": "usage",
"quota-bars-rows.test.ts": "gui",
diff --git a/src/adapters/qoder/adapter.ts b/src/adapters/qoder/adapter.ts
index 20c0b5581b..1bb8821b0b 100644
--- a/src/adapters/qoder/adapter.ts
+++ b/src/adapters/qoder/adapter.ts
@@ -4,6 +4,7 @@ import { mapReasoningEffort } from "../../reasoning-effort";
import { buildSystemPrompt } from "../coding-agent/protocol";
import { baseScopedEnv, runCodingAgentTurn, type CodingAgentDeps } from "../coding-agent/turn";
import { QODER_PROFILES, type QoderProfile } from "./profiles";
+import { QoderScaffoldFilter, QODER_SCAFFOLD_ERROR_CODE, qoderScaffoldErrorMessage } from "./scaffold-guard";
export type QoderAdapterDeps = CodingAgentDeps;
@@ -31,6 +32,73 @@ export function buildQoderArgs(parsed: OcxParsedRequest, provider: OcxProviderCo
return args;
}
+/**
+ * Wrap the turn's outbound channel with the scaffolding guard (#4190).
+ *
+ * The vendor CLI can put its own agent layer into the text channel despite being launched
+ * with tools and MCP disabled, and the shared stream-json parser forwards a text delta
+ * without inspecting it. This is the last point that is still qoder-specific, so the guard
+ * sits here rather than in the parser every coding-agent CLI shares.
+ *
+ * A terminal event flushes both channels first. The held tail is text the filter could not
+ * yet prove was not the start of a marker; dropping it would truncate a legitimate answer,
+ * and swallowing an entire response before forwarding a "done" reads downstream as an empty
+ * completion rather than as the refusal it is.
+ */
+export function guardQoderScaffolding(emit: (event: AdapterEvent) => void): (event: AdapterEvent) => void {
+ const textFilter = new QoderScaffoldFilter();
+ const thinkingFilter = new QoderScaffoldFilter();
+ let closed = false;
+
+ const refuse = (reason: string): void => {
+ if (closed) return;
+ closed = true;
+ emit({
+ type: "error",
+ message: qoderScaffoldErrorMessage(reason),
+ status: 502,
+ errorType: "upstream_error",
+ code: QODER_SCAFFOLD_ERROR_CODE,
+ // Intermittent, but a silent retry spends the operator's vendor credits on a
+ // contract violation the proxy cannot influence. Surface it instead.
+ retryable: false,
+ });
+ };
+
+ return (event: AdapterEvent): void => {
+ if (closed) return;
+ if (event.type === "text_delta") {
+ const cleaned = textFilter.push(event.text);
+ if (cleaned.text) emit({ ...event, text: cleaned.text });
+ if (cleaned.fail) refuse(cleaned.fail);
+ return;
+ }
+ if (event.type === "thinking_delta") {
+ const cleaned = thinkingFilter.push(event.thinking);
+ if (cleaned.text) emit({ ...event, thinking: cleaned.text });
+ if (cleaned.fail) refuse(cleaned.fail);
+ return;
+ }
+ if (event.type === "done" || event.type === "error" || event.type === "incomplete") {
+ const tail = textFilter.flush();
+ const reasoning = thinkingFilter.flush();
+ if (tail.text) emit({ type: "text_delta", text: tail.text });
+ if (reasoning.text) emit({ type: "thinking_delta", thinking: reasoning.text });
+ const fail = tail.fail ?? reasoning.fail;
+ // A vendor error already carries the better explanation for why the turn ended;
+ // only a terminal that claims success is replaced.
+ if (fail && event.type !== "error") {
+ refuse(fail);
+ return;
+ }
+ closed = true;
+ emit(event);
+ return;
+ }
+ emit(event);
+ };
+}
+
export function createQoderAdapter(provider: OcxProviderConfig, deps: QoderAdapterDeps = {}): ProviderAdapter {
return {
name: "qoder",
@@ -60,7 +128,7 @@ export function createQoderAdapter(provider: OcxProviderConfig, deps: QoderAdapt
provider,
parsed,
incoming,
- emit,
+ emit: guardQoderScaffolding(emit),
buildArgs: (_profile, req, prov) => buildQoderArgs(req, prov),
buildEnv: (profile, apiKey) => buildQoderChildEnv(profile as QoderProfile, apiKey),
deps,
diff --git a/src/adapters/qoder/scaffold-guard.ts b/src/adapters/qoder/scaffold-guard.ts
new file mode 100644
index 0000000000..8a1b6df620
--- /dev/null
+++ b/src/adapters/qoder/scaffold-guard.ts
@@ -0,0 +1,233 @@
+/**
+ * Vendor-scaffolding guard for the qoder route (#4190).
+ *
+ * The qoder route is contractually a text and reasoning surface: the CLI is spawned with
+ * `--tools "" --strict-mcp-config --setting-sources ""`, and Codex keeps tool ownership.
+ * The vendor CLI does not always honour that. It has been observed emitting its own agent
+ * layer into the assistant text channel — an MCP lazy-loading `` block
+ * listing the local machine's configured MCP servers, and framework tool-call markup with a
+ * mismatched closer. Both reached the client verbatim, because the shared stream-json parser
+ * forwards a text delta without inspecting it.
+ *
+ * Two shapes, two answers. A complete `` block is recognizable and
+ * self-delimiting, so it is removed and the surrounding answer survives. Anything else that
+ * carries a scaffolding signature is not repairable by guesswork — a partial tool-call block
+ * has no reliable end, and the text around it may already be the vendor's own agent
+ * narration rather than the model's answer — so the turn fails closed instead.
+ *
+ * The filter is a stream, not a regex over a finished string: a marker can be split across
+ * deltas, so a tail that is still a possible marker prefix is held back rather than emitted.
+ * Callers must therefore `flush()` before forwarding a terminal event, or a legitimate
+ * answer ending in "<" would lose its last character.
+ */
+
+/** Error code for a turn refused because vendor scaffolding reached the text channel. */
+export const QODER_SCAFFOLD_ERROR_CODE = "vendor_scaffold_detected";
+
+const REMINDER_OPEN = "` opened and `` closed — mismatched, which is what
+ * a model emitting remembered markup looks like, and exactly why reconstructing the intended
+ * text is not possible. A stray `` with no opener is in the same class:
+ * the block it belonged to was already partly forwarded, or never existed.
+ */
+const UNREPAIRABLE_MARKERS = ["", "", REMINDER_CLOSE] as const;
+
+/** Markers that end a block rather than start one; their prefix is never an answer. */
+const CLOSING_MARKERS = new Set(["", REMINDER_CLOSE]);
+
+/** Every marker the scanner must be able to recognize mid-split. */
+const ALL_MARKERS = [REMINDER_OPEN, ...UNREPAIRABLE_MARKERS] as const;
+
+const MAX_MARKER_LENGTH = Math.max(...ALL_MARKERS.map(marker => marker.length));
+
+/**
+ * True when `` so a truncated or self-closed tag still suppresses,
+ * which means `` in an ordinary answer would otherwise open a block and
+ * refuse the turn. A stem running to the end of the buffer still counts: more text may be
+ * arriving, and reading it as prose is the one reading that could release the block body.
+ */
+function reminderOpensHere(lowered: string, at: number): boolean {
+ const after = lowered[at + REMINDER_OPEN.length];
+ return after === undefined || /[\s/>]/.test(after);
+}
+
+/**
+ * Ceiling on a suppressed block before it is treated as unterminated.
+ *
+ * The block itself is discarded as it arrives, so this is not a memory bound — only the
+ * trailing bytes needed to spot a split closer are retained. It bounds how much of a turn a
+ * single unclosed reminder is allowed to swallow silently before the turn is refused.
+ */
+const MAX_SUPPRESSED_CHARS = 64 * 1024;
+
+/** Result of feeding one chunk: the text safe to forward, and a refusal reason once tripped. */
+export interface ScaffoldFilterResult {
+ /** Text cleared for the client. Empty when everything in the chunk was held or dropped. */
+ text: string;
+ /** Non-null exactly once, on the chunk that trips the guard. */
+ fail: string | null;
+}
+
+/** Longest suffix of `text` that could still grow into one of the markers. */
+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();
+ for (const marker of ALL_MARKERS) {
+ if (marker.length > length && marker.startsWith(suffix)) return length;
+ }
+ }
+ return 0;
+}
+
+/**
+ * Streaming scaffolding filter for one channel (text or reasoning).
+ *
+ * One instance per channel: the two never share suppression state, so a reminder block
+ * opened in reasoning cannot swallow the answer text.
+ */
+export class QoderScaffoldFilter {
+ private mode: "pass" | "suppress" = "pass";
+ private pending = "";
+ private suppressedTail = "";
+ private suppressedChars = 0;
+ private failed = false;
+ /** True once a reminder block has been suppressed on this channel. */
+ private suppressedBlock = false;
+ /** Open reminder blocks; only the closer that unwinds the last one ends suppression. */
+ private suppressDepth = 0;
+
+ push(chunk: string): ScaffoldFilterResult {
+ if (this.failed || !chunk) return { text: "", fail: null };
+ let cleared = "";
+ let buffer = this.mode === "pass" ? this.pending + chunk : chunk;
+ this.pending = "";
+
+ for (;;) {
+ if (this.mode === "suppress") {
+ const scan = this.suppressedTail + buffer;
+ const scanned = scan.toLowerCase();
+ // Unwind nesting rather than ending at the first closer. A reminder containing another
+ // reminder would otherwise hand the outer block's remaining body — the MCP server list
+ // in the reported leak — to the client as the model's answer, with a successful
+ // terminal and nothing to signal that anything had gone wrong.
+ let cursor = 0;
+ let close = -1;
+ for (;;) {
+ const nextClose = scanned.indexOf(REMINDER_CLOSE, cursor);
+ if (nextClose < 0) break;
+ let nextOpen = scanned.indexOf(REMINDER_OPEN, cursor);
+ while (nextOpen >= 0 && !reminderOpensHere(scanned, nextOpen)) {
+ nextOpen = scanned.indexOf(REMINDER_OPEN, nextOpen + 1);
+ }
+ if (nextOpen >= 0 && nextOpen < nextClose) {
+ this.suppressDepth += 1;
+ cursor = nextOpen + REMINDER_OPEN.length;
+ continue;
+ }
+ this.suppressDepth -= 1;
+ cursor = nextClose + REMINDER_CLOSE.length;
+ if (this.suppressDepth === 0) {
+ close = nextClose;
+ break;
+ }
+ }
+ if (close < 0) {
+ this.suppressedChars += buffer.length;
+ if (this.suppressedChars > MAX_SUPPRESSED_CHARS) {
+ return this.fail(cleared, `an unterminated ${REMINDER_OPEN}> block`);
+ }
+ // The block is discarded as it arrives; only enough tail to spot a split closer is kept.
+ // The tail must cover a split opener too, now that nesting is counted.
+ this.suppressedTail = scan.slice(Math.max(0, scan.length - (MAX_MARKER_LENGTH - 1)));
+ return { text: cleared, fail: null };
+ }
+ buffer = scan.slice(close + REMINDER_CLOSE.length);
+ this.mode = "pass";
+ this.suppressedTail = "";
+ this.suppressedChars = 0;
+ continue;
+ }
+
+ let earliest = -1;
+ let found = "";
+ const lowered = buffer.toLowerCase();
+ for (const marker of ALL_MARKERS) {
+ let at = lowered.indexOf(marker);
+ while (at >= 0 && marker === REMINDER_OPEN && !reminderOpensHere(lowered, at)) {
+ at = lowered.indexOf(marker, at + 1);
+ }
+ if (at < 0) continue;
+ // A closer sitting exactly where an opener starts cannot happen, so ties are impossible.
+ if (earliest < 0 || at < earliest) {
+ earliest = at;
+ found = marker;
+ }
+ }
+
+ if (earliest < 0) {
+ const held = heldSuffixLength(buffer);
+ cleared += held > 0 ? buffer.slice(0, buffer.length - held) : buffer;
+ this.pending = held > 0 ? buffer.slice(buffer.length - held) : "";
+ return { text: cleared, fail: null };
+ }
+
+ // Text produced before the scaffolding is the model's own answer, and it is kept — but
+ // only while this channel has not already suppressed a block. Once it has, the text
+ // between that block and an unrepairable marker is not an answer that happens to
+ // precede a leak; it is the region the vendor was narrating in, and in the reported
+ // case it carries the MCP server list. Forwarding it on the way to a refusal would
+ // publish exactly what the refusal exists to contain.
+ // A closer with no opener never keeps its prefix either: the block it belonged to was
+ // already partly forwarded or never existed, so the text ahead of it is that body.
+ if (!CLOSING_MARKERS.has(found) && (found === REMINDER_OPEN || !this.suppressedBlock)) {
+ cleared += buffer.slice(0, earliest);
+ }
+ if (found !== REMINDER_OPEN) return this.fail(cleared, `vendor tool-call markup (${found})`);
+ this.suppressedBlock = true;
+ this.mode = "suppress";
+ this.suppressDepth = 1;
+ this.suppressedTail = "";
+ this.suppressedChars = 0;
+ buffer = buffer.slice(earliest + REMINDER_OPEN.length);
+ }
+ }
+
+ /** Release the held tail. Call before forwarding a terminal event, never mid-stream. */
+ flush(): ScaffoldFilterResult {
+ if (this.failed) return { text: "", fail: null };
+ if (this.mode === "suppress") return this.fail("", `an unterminated ${REMINDER_OPEN}> block`);
+ const text = this.pending;
+ this.pending = "";
+ return { text, fail: null };
+ }
+
+ private fail(cleared: string, reason: string): ScaffoldFilterResult {
+ this.failed = true;
+ this.pending = "";
+ this.suppressedTail = "";
+ return { text: cleared, fail: reason };
+ }
+}
+
+/**
+ * Message for a refused turn.
+ *
+ * It names the marker class and nothing else. The leaked reminder in the report enumerated
+ * the operator's own MCP servers, so echoing the offending text back — into an error the
+ * client renders, and that a user may paste into an issue — would publish the thing this
+ * guard exists to contain.
+ */
+export function qoderScaffoldErrorMessage(reason: string): string {
+ return `Qoder CLI emitted ${reason} in the assistant text 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 vendor agent scaffolding to the client.";
+}
diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json
index 486a6de544..ba4339c178 100644
--- a/tests/fixtures/test-layout-expected.json
+++ b/tests/fixtures/test-layout-expected.json
@@ -832,6 +832,7 @@
"proxy-liveness.test.ts": "server",
"qoder-adapter.test.ts": "providers",
"qoder-live-models.test.ts": "providers",
+ "qoder-scaffold-guard.test.ts": "providers",
"quota-401-recovery-runtime.test.ts": "usage",
"quota-401-recovery.test.ts": "usage",
"quota-bars-rows.test.ts": "gui",
diff --git a/tests/providers/qoder-scaffold-guard.test.ts b/tests/providers/qoder-scaffold-guard.test.ts
new file mode 100644
index 0000000000..65f06d7bc1
--- /dev/null
+++ b/tests/providers/qoder-scaffold-guard.test.ts
@@ -0,0 +1,232 @@
+import { describe, expect, test } from "bun:test";
+import type { AdapterEvent } from "../../src/types";
+import { guardQoderScaffolding } from "../../src/adapters/qoder/adapter";
+import {
+ QoderScaffoldFilter,
+ QODER_SCAFFOLD_ERROR_CODE,
+} from "../../src/adapters/qoder/scaffold-guard";
+
+/**
+ * #4190: the qoder route is documented as a text and reasoning surface with the vendor CLI's
+ * own tools and MCP servers disabled, yet an MCP lazy-loading reminder listing the operator's
+ * configured servers, and vendor tool-call markup with a mismatched closer, reached the
+ * client as assistant text.
+ */
+
+const REMINDER = "MCP lazy-loading is active.\n## Connected MCP servers\n"
+ + "- internal-notes\n- deploy-keys\nUse mcp_list / mcp_get / mcp_call.";
+
+const TOOL_MARKUP = "\ncd /srv/private && git status\n";
+
+function collect(): { events: AdapterEvent[]; emit: (event: AdapterEvent) => void } {
+ const events: AdapterEvent[] = [];
+ return { events, emit: event => { events.push(event); } };
+}
+
+function textOf(events: AdapterEvent[]): string {
+ return events.map(event => (event.type === "text_delta" ? event.text : "")).join("");
+}
+
+describe("QoderScaffoldFilter", () => {
+ test("removes a complete reminder block and keeps the answer around it", () => {
+ const filter = new QoderScaffoldFilter();
+ const first = filter.push(`Before.${REMINDER}After.`);
+ expect(first.fail).toBeNull();
+ expect(first.text + filter.flush().text).toBe("Before.After.");
+ });
+
+ test("catches a marker split across deltas", () => {
+ const filter = new QoderScaffoldFilter();
+ // The opening tag arrives in three pieces; a per-delta scan would miss it entirely.
+ const parts = ["Answer. secret server list Done."];
+ const out = parts.map(part => filter.push(part));
+ expect(out.every(result => result.fail === null)).toBe(true);
+ expect(out.map(result => result.text).join("") + filter.flush().text).toBe("Answer. Done.");
+ expect(out.map(result => result.text).join("")).not.toContain("secret server list");
+ });
+
+ test("releases a held tail that never became a marker", () => {
+ const filter = new QoderScaffoldFilter();
+ // "<" is a live marker prefix, so it cannot be forwarded until the stream ends.
+ const pushed = filter.push("compare a < b and a {
+ const filter = new QoderScaffoldFilter();
+ const result = filter.push(`Checking the repositories.\n${TOOL_MARKUP}`);
+ expect(result.text).toBe("Checking the repositories.\n");
+ expect(result.fail).toContain(" {
+ // The block it belonged to was already partly forwarded, or never existed.
+ expect(new QoderScaffoldFilter().push("tail").fail).toContain("");
+ });
+
+ test("does not forward the region between a suppressed block and a refusal", () => {
+ // The text before the FIRST marker is the model's answer and is kept. The text after a
+ // block this filter already swallowed is the vendor's own narration, and in the reported
+ // leak that region is the MCP server list itself.
+ const filter = new QoderScaffoldFilter();
+ const result = filter.push(
+ `a\n## Connected MCP servers\n- deploy-keys`,
+ );
+ expect(result.text).toBe("");
+ expect(result.text).not.toContain("deploy-keys");
+ expect(result.fail).toContain("");
+ });
+
+ test("does not forward vendor narration that sits between a reminder and tool markup", () => {
+ const filter = new QoderScaffoldFilter();
+ const result = filter.push(`Status.${REMINDER}\n- deploy-keys\n${TOOL_MARKUP}`);
+ expect(result.text).toBe("Status.");
+ expect(result.text).not.toContain("deploy-keys");
+ expect(result.fail).toContain(" {
+ // Ending at the first closer handed the outer block's remaining body to the client as
+ // the model's answer, with a successful terminal and no signal that anything was wrong.
+ const filter = new QoderScaffoldFilter();
+ const result = filter.push(
+ "outerinner\n## Connected MCP servers\n- deploy-keys",
+ );
+ expect(result.text).toBe("");
+ const flushed = filter.flush();
+ expect(flushed.text).not.toContain("deploy-keys");
+ expect(flushed.fail).toContain("unterminated");
+ });
+
+ test("keeps the answer after a nested reminder that does close", () => {
+ const filter = new QoderScaffoldFilter();
+ const result = filter.push(
+ "oi- deploy-keys Done.",
+ );
+ expect(result.text).toBe(" Done.");
+ expect(result.fail).toBeNull();
+ });
+
+ test("counts nesting even when the tags are split across deltas", () => {
+ const filter = new QoderScaffoldFilter();
+ const parts = ["oi- deploy-keys Done."];
+ const out = parts.map(part => filter.push(part));
+ expect(out.map(result => result.text).join("")).toBe(" Done.");
+ expect(out.every(result => result.fail === null)).toBe(true);
+ });
+
+ test("a closer with no opener forwards nothing ahead of it", () => {
+ // The prefix of a stray closer is the lost block's body, not an answer that preceded it.
+ const filter = new QoderScaffoldFilter();
+ const result = filter.push("## Connected MCP servers\n- deploy-keys");
+ expect(result.text).toBe("");
+ expect(result.fail).toContain("");
+ expect(new QoderScaffoldFilter().push("cd /srv/private && git status").text).toBe("");
+ });
+
+ test("catches an invoke block that carries no attributes", () => {
+ // "", so the command shipped ahead of the refusal.
+ const filter = new QoderScaffoldFilter();
+ const result = filter.push("Checking.\n\ncd /srv/private && git status\n");
+ expect(result.text).toBe("Checking.\n");
+ expect(result.text).not.toContain("git status");
+ expect(result.fail).toContain("");
+ });
+
+ test("does not open a block on a word that merely starts with the tag name", () => {
+ // The opener is matched without its ">", so it needs a token boundary of its own.
+ const filter = new QoderScaffoldFilter();
+ const result = filter.push("the are documented");
+ expect(result.text + filter.flush().text).toBe("the are documented");
+ expect(result.fail).toBeNull();
+ });
+
+ test("fails closed when a reminder is never terminated", () => {
+ const filter = new QoderScaffoldFilter();
+ expect(filter.push("ok listing servers").fail).toBeNull();
+ expect(filter.flush().fail).toContain("unterminated");
+ });
+
+ test("latches: nothing more escapes after the guard trips", () => {
+ const filter = new QoderScaffoldFilter();
+ expect(filter.push(TOOL_MARKUP).fail).not.toBeNull();
+ expect(filter.push("more vendor narration")).toEqual({ text: "", fail: null });
+ expect(filter.flush()).toEqual({ text: "", fail: null });
+ });
+});
+
+describe("guardQoderScaffolding", () => {
+ test("strips the reminder and still completes the turn", () => {
+ const { events, emit } = collect();
+ const guarded = guardQoderScaffolding(emit);
+ guarded({ type: "text_delta", text: `Here is the status.${REMINDER}` });
+ guarded({ type: "done", stopReason: "stop" });
+ expect(textOf(events)).toBe("Here is the status.");
+ expect(textOf(events)).not.toContain("mcp_call");
+ expect(events[events.length - 1]!.type).toBe("done");
+ });
+
+ test("flushes the held tail before the terminal event", () => {
+ const { events, emit } = collect();
+ const guarded = guardQoderScaffolding(emit);
+ // Without the flush this answer would arrive truncated, and an answer that is entirely
+ // held back would reach the empty-completion guard as a successful but empty turn.
+ guarded({ type: "text_delta", text: "1 < 2" });
+ guarded({ type: "done", stopReason: "stop" });
+ expect(textOf(events)).toBe("1 < 2");
+ expect(events[events.length - 1]!.type).toBe("done");
+ });
+
+ test("refuses the turn when tool-call markup leaks, and swallows the vendor's success", () => {
+ const { events, emit } = collect();
+ const guarded = guardQoderScaffolding(emit);
+ guarded({ type: "text_delta", text: `Checking.\n${TOOL_MARKUP}` });
+ guarded({ type: "done", stopReason: "stop" });
+ expect(textOf(events)).toBe("Checking.\n");
+ const terminal = events[events.length - 1]!;
+ expect(terminal.type).toBe("error");
+ if (terminal.type !== "error") throw new Error("expected an error terminal");
+ expect(terminal.code).toBe(QODER_SCAFFOLD_ERROR_CODE);
+ expect(terminal.status).toBe(502);
+ expect(terminal.retryable).toBe(false);
+ expect(terminal.message).not.toContain("git status");
+ expect(terminal.message).not.toContain("mcp_call");
+ expect(events.filter(event => event.type === "done")).toHaveLength(0);
+ });
+
+ test("guards the reasoning channel independently of the text channel", () => {
+ const { events, emit } = collect();
+ const guarded = guardQoderScaffolding(emit);
+ guarded({ type: "thinking_delta", thinking: `Planning.${REMINDER}Continue.` });
+ guarded({ type: "text_delta", text: "Answer." });
+ guarded({ type: "done", stopReason: "stop" });
+ const thinking = events.filter(event => event.type === "thinking_delta")
+ .map(event => event.type === "thinking_delta" ? event.thinking : "").join("");
+ expect(thinking).toBe("Planning.Continue.");
+ expect(textOf(events)).toBe("Answer.");
+ });
+
+ test("forwards the vendor's own error rather than replacing it", () => {
+ const { events, emit } = collect();
+ const guarded = guardQoderScaffolding(emit);
+ guarded({ type: "text_delta", text: "partial never closed" });
+ guarded({ type: "error", message: "Qoder CLI exited with code 118", status: 429 });
+ const terminal = events[events.length - 1]!;
+ expect(terminal.type).toBe("error");
+ if (terminal.type !== "error") throw new Error("expected an error terminal");
+ // The vendor said why the turn ended; the guard's job here was only to drop the block.
+ expect(terminal.message).toBe("Qoder CLI exited with code 118");
+ expect(textOf(events)).toBe("partial ");
+ });
+
+ test("passes unrelated events through untouched", () => {
+ const { events, emit } = collect();
+ const guarded = guardQoderScaffolding(emit);
+ guarded({ type: "tool_call_start", id: "call_1", name: "exec" });
+ guarded({ type: "done", stopReason: "stop" });
+ expect(events.map(event => event.type)).toEqual(["tool_call_start", "done"]);
+ });
+});