Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 32 additions & 13 deletions src/adapters/qoder/scaffold-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,31 @@ const MAX_MARKER_LENGTH = Math.max(...ALL_MARKERS.map(marker => marker.length));
* 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];
function reminderOpensHere(text: string, at: number): boolean {
const after = text[at + REMINDER_OPEN.length];
return after === undefined || /[\s/>]/.test(after);
}

/**
* Find an ASCII marker without transforming `text`.
*
* Marker offsets must remain offsets into the original string. Unicode lowercasing can expand
* one code unit into several (for example, `İ` becomes `i` plus a combining dot), so an index
* obtained from `text.toLowerCase()` is unsafe to reuse with `text.slice()`.
*/
function indexOfMarker(text: string, marker: string, from = 0): number {
const last = text.length - marker.length;
outer: for (let at = Math.max(0, from); at <= last; at++) {
for (let offset = 0; offset < marker.length; offset++) {
const code = text.charCodeAt(at + offset);
const folded = code >= 65 && code <= 90 ? code + 32 : code;
if (folded !== marker.charCodeAt(offset)) continue outer;
}
return at;
}
return -1;
}

/**
* Ceiling on a suppressed block before it is treated as unterminated.
*
Expand All @@ -80,9 +100,10 @@ export interface ScaffoldFilterResult {
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;
if (marker.length > length && indexOfMarker(text, marker.slice(0, length), text.length - length) >= 0) {
return length;
}
}
}
return 0;
Expand Down Expand Up @@ -114,19 +135,18 @@ export class QoderScaffoldFilter {
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);
const nextClose = indexOfMarker(scan, 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);
let nextOpen = indexOfMarker(scan, REMINDER_OPEN, cursor);
while (nextOpen >= 0 && !reminderOpensHere(scan, nextOpen)) {
nextOpen = indexOfMarker(scan, REMINDER_OPEN, nextOpen + 1);
}
if (nextOpen >= 0 && nextOpen < nextClose) {
this.suppressDepth += 1;
Expand Down Expand Up @@ -159,11 +179,10 @@ export class QoderScaffoldFilter {

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);
let at = indexOfMarker(buffer, marker);
while (at >= 0 && marker === REMINDER_OPEN && !reminderOpensHere(buffer, at)) {
at = indexOfMarker(buffer, marker, at + 1);
}
if (at < 0) continue;
// A closer sitting exactly where an opener starts cannot happen, so ties are impossible.
Expand Down
27 changes: 27 additions & 0 deletions tests/providers/qoder-scaffold-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,22 @@ describe("QoderScaffoldFilter", () => {
expect(first.text + filter.flush().text).toBe("Before.After.");
});

test("uses original-string offsets when Unicode lowercasing would expand", () => {
const expandingPrefix = "İ".repeat(64);
const filter = new QoderScaffoldFilter();
const result = filter.push(`${expandingPrefix}${REMINDER}After.`);
expect(result.fail).toBeNull();
expect(result.text + filter.flush().text).toBe(`${expandingPrefix}After.`);
expect(result.text).not.toContain("internal-notes");
});

test("uses original-string offsets to find a closer after expanding Unicode", () => {
const filter = new QoderScaffoldFilter();
const result = filter.push(`<system-reminder>${"İ".repeat(64)}</SYSTEM-REMINDER>After.`);
expect(result.fail).toBeNull();
expect(result.text + filter.flush().text).toBe("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.
Expand Down Expand Up @@ -159,6 +175,17 @@ describe("QoderScaffoldFilter", () => {
});

describe("guardQoderScaffolding", () => {
test("never emits a reminder after a Unicode case-folding expansion", () => {
const { events, emit } = collect();
const guarded = guardQoderScaffolding(emit);
const prefix = "İ".repeat(64);
guarded({ type: "text_delta", text: `${prefix}${REMINDER}` });
guarded({ type: "done", stopReason: "stop" });
expect(textOf(events)).toBe(prefix);
expect(textOf(events)).not.toContain("internal-notes");
expect(events[events.length - 1]!.type).toBe("done");
});

test("strips the reminder and still completes the turn", () => {
const { events, emit } = collect();
const guarded = guardQoderScaffolding(emit);
Expand Down
Loading