From 4489d68bd0cc73e1fbebb02b21aceef8889b90d4 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 20 Sep 2026 21:59:22 +0900 Subject: [PATCH 1/8] fix(codebuddy): make scaffold scanning linear --- src/adapters/codebuddy/scaffold-guard.ts | 21 ++++++++++----------- tests/providers/codebuddy-adapter.test.ts | 12 ++++++++++++ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/adapters/codebuddy/scaffold-guard.ts b/src/adapters/codebuddy/scaffold-guard.ts index 707f90ae1c0..5d1b689d925 100644 --- a/src/adapters/codebuddy/scaffold-guard.ts +++ b/src/adapters/codebuddy/scaffold-guard.ts @@ -28,9 +28,9 @@ interface ScanResult { 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); +function prefixAtEnd(loweredText: string, at: number, expected: string): boolean { + const remaining = loweredText.length - at; + return remaining <= expected.length && expected.startsWith(loweredText.slice(at)); } /** @@ -46,6 +46,7 @@ function scan( initialFence: "`" | "~" | null, initialLineStart: boolean, ): ScanResult { + const loweredText = text.toLowerCase(); let fence = initialFence; let lineStart = initialLineStart; let index = 0; @@ -60,13 +61,12 @@ function scan( lineStart = false; continue; } - if (fenceMarkers.some(marker => prefixAtEnd(text, index, marker))) { + if (fenceMarkers.some(marker => prefixAtEnd(loweredText, 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)) { + if (loweredText.startsWith(DSML_CALLS_LINE, index)) { const afterCalls = index + DSML_CALLS_LINE.length; let invokeAt = -1; if (text[afterCalls] === "\n") invokeAt = afterCalls + 1; @@ -76,16 +76,15 @@ function scan( } if (invokeAt >= 0) { - const invokeRest = text.slice(invokeAt).toLowerCase(); - const invokeNameStart = invokeRest[DSML_INVOKE_PREFIX.length]; - if (invokeRest.startsWith(DSML_INVOKE_PREFIX) && invokeNameStart && !/[\s"]/.test(invokeNameStart)) { + const invokeNameStart = loweredText[invokeAt + DSML_INVOKE_PREFIX.length]; + if (loweredText.startsWith(DSML_INVOKE_PREFIX, invokeAt) && invokeNameStart && !/[\s"]/.test(invokeNameStart)) { return { safe: text.slice(0, index), held: "", fail: true, fence, lineStart }; } - if (invokeRest.length === 0 || DSML_INVOKE_PREFIX.startsWith(invokeRest)) { + if (invokeAt === text.length || prefixAtEnd(loweredText, invokeAt, DSML_INVOKE_PREFIX)) { return { safe: text.slice(0, index), held: text.slice(index), fail: false, fence, lineStart }; } } - } else if (prefixAtEnd(text, index, DSML_CALLS_LINE)) { + } else if (prefixAtEnd(loweredText, index, DSML_CALLS_LINE)) { return { safe: text.slice(0, index), held: text.slice(index), fail: false, fence, lineStart }; } } diff --git a/tests/providers/codebuddy-adapter.test.ts b/tests/providers/codebuddy-adapter.test.ts index c03db18ce68..69d20273ff0 100644 --- a/tests/providers/codebuddy-adapter.test.ts +++ b/tests/providers/codebuddy-adapter.test.ts @@ -346,6 +346,18 @@ describe("codebuddy runTurn streams a headless turn", () => { ]); }); + test("processes a large multiline delta without repeated suffix work", () => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + const answer = "a\n".repeat(40_000); + const startedAt = performance.now(); + + guarded({ type: "text_delta", text: answer }); + + expect(performance.now() - startedAt).toBeLessThan(1_000); + expect(events).toEqual([{ type: "text_delta", text: answer }]); + }); + test("delivers quoted and inline-code DSML literals unchanged", () => { const events: AdapterEvent[] = []; const guarded = guardCodeBuddyScaffolding(event => events.push(event)); From f700c56172d79f1222690d7a1ccfb9d6cc4c53bf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:22:17 +0000 Subject: [PATCH 2/8] test(openai-chat): declare role acceptance in suites that assert the forwarded role (#5334 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5334 made the developer wire role tri-state: an undeclared destination folds it to system. Two suites asserting role:"developer" on the Chat wire were missed because they are about tool-result repair ordering and document parts, not role selection — declare the destination, per the convention the change established. Verified: both files fail on dev@600075d2 with system-for-developer wire roles and pass with the declaration. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts | 4 ++++ tests/responses/chat-inline-document-bytes.test.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts b/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts index 41a61c21023..075e3c33186 100644 --- a/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts +++ b/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts @@ -12,6 +12,10 @@ const provider: OcxProviderConfig = { baseUrl: "https://example.test/v1", apiKey: "sk-test", authMode: "key", + // The wire role folds to `system` unless a destination is recorded as accepting + // `developer`; this suite is about tool-result repair ordering, so it declares the + // destination rather than asserting the default. + foldDeveloperRoleToSystem: false, }; interface ChatMsg { diff --git a/tests/responses/chat-inline-document-bytes.test.ts b/tests/responses/chat-inline-document-bytes.test.ts index dd88fa05788..a718c1c376a 100644 --- a/tests/responses/chat-inline-document-bytes.test.ts +++ b/tests/responses/chat-inline-document-bytes.test.ts @@ -28,6 +28,10 @@ const chatProvider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://gateway.example.internal/v1", apiKey: "k", + // The wire role folds to `system` unless a destination is recorded as accepting + // `developer`; the document test asserts the role a turn keeps, so it declares the + // destination rather than asserting the default. + foldDeveloperRoleToSystem: false, }; const anthropicProvider = { adapter: "anthropic", From cfcc3ea2cfbb6a82d65e8e648971fedd9fa48919 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:26:31 +0000 Subject: [PATCH 3/8] test(responses): declare developer-role acceptance in the inline-document suite #5334 made foldDeveloperRoleToSystem tri-state: unset now folds developer to system, and only an explicit false records a destination that accepts the role. This suite's chatProvider fixture declared nothing, so its developer-turn assertion folded and failed on the PR merge. Record acceptance, matching the conformance-fixture fix in #5341. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/responses/chat-inline-document-bytes.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/responses/chat-inline-document-bytes.test.ts b/tests/responses/chat-inline-document-bytes.test.ts index dd88fa05788..83c751fbbbe 100644 --- a/tests/responses/chat-inline-document-bytes.test.ts +++ b/tests/responses/chat-inline-document-bytes.test.ts @@ -28,6 +28,8 @@ const chatProvider: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://gateway.example.internal/v1", apiKey: "k", + // The destination on record accepts `developer`, so the role is forwarded rather than folded. + foldDeveloperRoleToSystem: false, }; const anthropicProvider = { adapter: "anthropic", From f14e2d2a353081cf05874b9b392b0b53200d2e69 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 13:40:29 +0000 Subject: [PATCH 4/8] test(openai-chat): declare developer-role acceptance in the dangling-toolcalls suite #5334 made foldDeveloperRoleToSystem tri-state: unset now folds developer to system, and only an explicit false records a destination that accepts the role. This suite's provider fixture declared nothing, so its deferred-barrier assertions folded and T1/T5/T6 failed on the PR merge. Record acceptance, matching the inline-document and conformance-fixture updates. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts b/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts index 41a61c21023..539a01549cd 100644 --- a/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts +++ b/tests/adapters/openai/openai-chat-dangling-toolcalls.test.ts @@ -12,6 +12,9 @@ const provider: OcxProviderConfig = { baseUrl: "https://example.test/v1", apiKey: "sk-test", authMode: "key", + // These cases assert which slot the deferred barrier lands in, and the wire role folds + // to `system` unless a destination is recorded as accepting `developer`. + foldDeveloperRoleToSystem: false, }; interface ChatMsg { From 008775df77fbfd7eb176025aa0fd93c0e44e71a2 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:03:50 +0000 Subject: [PATCH 5/8] fix(ci): disable updater artifacts for the unsigned widget build The widget job builds the desktop app without TAURI_SIGNING_PRIVATE_KEY, but tauri.conf.json sets bundle.createUpdaterArtifacts and an updater pubkey, which makes updater signing mandatory and fails the bundler with 'A public key has been found, but no private key'. Override createUpdaterArtifacts off via tauri build --config so the unsigned CI build produces only the .app it verifies. Release builds keep producing signed updater artifacts through release.yml. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 5 ++++- desktop/README.md | 7 +++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3dbb2f7dede..3a27cd18983 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1193,7 +1193,10 @@ jobs: - name: Build unsigned desktop app working-directory: desktop - run: bunx tauri build --ci --bundles app + # Updater artifacts stay disabled here: with the pubkey configured in + # tauri.conf.json they require TAURI_SIGNING_PRIVATE_KEY, which only + # exists as a release-workflow secret. + run: bunx tauri build --ci --bundles app --config '{"bundle":{"createUpdaterArtifacts":false}}' - name: Verify WidgetKit appex and desktop app run: | diff --git a/desktop/README.md b/desktop/README.md index 9106a50f67f..0cb540f0c86 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -45,6 +45,13 @@ bun run prepare-widget bunx tauri build --ci --bundles app,dmg ``` +Without the signing key, disable updater artifacts or the bundler errors out +(`A public key has been found, but no private key`): + +```sh +bunx tauri build --ci --bundles app,dmg --config '{"bundle":{"createUpdaterArtifacts":false}}' +``` + Release signing is supplied through environment variables: ```sh From 133219325514d58b41f6adc6069496c27fd78f50 Mon Sep 17 00:00:00 2001 From: luvs01 Date: Mon, 21 Sep 2026 01:04:13 +0000 Subject: [PATCH 6/8] ci: retrigger checks (empty commit) From 5eee0037e4f1aad4545e234424178d2036bb736b Mon Sep 17 00:00:00 2001 From: luvs01 Date: Mon, 21 Sep 2026 01:06:14 +0000 Subject: [PATCH 7/8] ci: retrigger checks (empty commit; dev merge conflicts) From 425764fe0976cdd6b866cdefe95514ac9fa060ed Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:47:28 +0000 Subject: [PATCH 8/8] fix(codebuddy): keep scan offsets valid by folding case per code unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unicode lowercasing can expand a code point (e.g. İ -> i+combining dot), which shifted loweredText positions away from text offsets and let a calls+invoke scaffold slip through unflagged. Compare markers with ASCII-only folding so indices stay aligned, and replace the wall-clock perf assertion with a scaled-input check plus an expansion regression test. Co-Authored-By: Epinephrine --- src/adapters/codebuddy/scaffold-guard.ts | 38 +++++++++++++++++------ tests/providers/codebuddy-adapter.test.ts | 33 ++++++++++++++++---- 2 files changed, 55 insertions(+), 16 deletions(-) diff --git a/src/adapters/codebuddy/scaffold-guard.ts b/src/adapters/codebuddy/scaffold-guard.ts index 5d1b689d925..6f913233e1e 100644 --- a/src/adapters/codebuddy/scaffold-guard.ts +++ b/src/adapters/codebuddy/scaffold-guard.ts @@ -28,9 +28,28 @@ interface ScanResult { lineStart: boolean; } -function prefixAtEnd(loweredText: string, at: number, expected: string): boolean { - const remaining = loweredText.length - at; - return remaining <= expected.length && expected.startsWith(loweredText.slice(at)); +// Probe with ASCII-only case folding rather than toLowerCase(): Unicode lowercasing can expand +// a code point (İ, ʼn, ligatures), shifting folded-text offsets away from `text` positions and +// silently disabling detection. `expected` must already be lowercase. +function asciiFold(code: number): number { + return code >= 0x41 && code <= 0x5a ? code + 0x20 : code; +} + +function startsWithFolded(text: string, index: number, expected: string): boolean { + if (index + expected.length > text.length) return false; + for (let i = 0; i < expected.length; i++) { + if (asciiFold(text.charCodeAt(index + i)) !== expected.charCodeAt(i)) return false; + } + return true; +} + +function prefixAtEnd(text: string, at: number, expected: string): boolean { + const remaining = text.length - at; + if (remaining > expected.length) return false; + for (let i = 0; i < remaining; i++) { + if (asciiFold(text.charCodeAt(at + i)) !== expected.charCodeAt(i)) return false; + } + return true; } /** @@ -46,7 +65,6 @@ function scan( initialFence: "`" | "~" | null, initialLineStart: boolean, ): ScanResult { - const loweredText = text.toLowerCase(); let fence = initialFence; let lineStart = initialLineStart; let index = 0; @@ -61,12 +79,12 @@ function scan( lineStart = false; continue; } - if (fenceMarkers.some(marker => prefixAtEnd(loweredText, index, marker))) { + if (fenceMarkers.some(marker => prefixAtEnd(text, index, marker))) { return { safe: text.slice(0, index), held: text.slice(index), fail: false, fence, lineStart }; } if (!fence) { - if (loweredText.startsWith(DSML_CALLS_LINE, index)) { + if (startsWithFolded(text, index, DSML_CALLS_LINE)) { const afterCalls = index + DSML_CALLS_LINE.length; let invokeAt = -1; if (text[afterCalls] === "\n") invokeAt = afterCalls + 1; @@ -76,15 +94,15 @@ function scan( } if (invokeAt >= 0) { - const invokeNameStart = loweredText[invokeAt + DSML_INVOKE_PREFIX.length]; - if (loweredText.startsWith(DSML_INVOKE_PREFIX, invokeAt) && invokeNameStart && !/[\s"]/.test(invokeNameStart)) { + const invokeNameStart = text[invokeAt + DSML_INVOKE_PREFIX.length]; + if (startsWithFolded(text, invokeAt, DSML_INVOKE_PREFIX) && invokeNameStart && !/[\s"]/.test(invokeNameStart)) { return { safe: text.slice(0, index), held: "", fail: true, fence, lineStart }; } - if (invokeAt === text.length || prefixAtEnd(loweredText, invokeAt, DSML_INVOKE_PREFIX)) { + if (invokeAt === text.length || prefixAtEnd(text, invokeAt, DSML_INVOKE_PREFIX)) { return { safe: text.slice(0, index), held: text.slice(index), fail: false, fence, lineStart }; } } - } else if (prefixAtEnd(loweredText, index, DSML_CALLS_LINE)) { + } else if (prefixAtEnd(text, index, DSML_CALLS_LINE)) { return { safe: text.slice(0, index), held: text.slice(index), fail: false, fence, lineStart }; } } diff --git a/tests/providers/codebuddy-adapter.test.ts b/tests/providers/codebuddy-adapter.test.ts index ec0e5b05595..f7c0be7a25d 100644 --- a/tests/providers/codebuddy-adapter.test.ts +++ b/tests/providers/codebuddy-adapter.test.ts @@ -378,16 +378,37 @@ describe("codebuddy runTurn streams a headless turn", () => { ]); }); - test("processes a large multiline delta without repeated suffix work", () => { + test("scans a large multiline delta in time linear in its length", () => { + const run = (lines: number): { events: AdapterEvent[]; elapsed: number } => { + const events: AdapterEvent[] = []; + const guarded = guardCodeBuddyScaffolding(event => events.push(event)); + const answer = "a\n".repeat(lines); + const startedAt = performance.now(); + guarded({ type: "text_delta", text: answer }); + return { events, elapsed: performance.now() - startedAt }; + }; + + const baseline = run(40_000); + const scaled = run(160_000); + + // Four times the input must stay near 4x cost; a scan re-walking its suffix could not fit. + expect(scaled.elapsed).toBeLessThan(Math.max(baseline.elapsed * 8, 250)); + expect(baseline.events).toEqual([{ type: "text_delta", text: "a\n".repeat(40_000) }]); + expect(scaled.events).toEqual([{ type: "text_delta", text: "a\n".repeat(160_000) }]); + }); + + test("still refuses scaffolding after a code point that expands when lowercased", () => { const events: AdapterEvent[] = []; const guarded = guardCodeBuddyScaffolding(event => events.push(event)); - const answer = "a\n".repeat(40_000); - const startedAt = performance.now(); - guarded({ type: "text_delta", text: answer }); + // İ (U+0130) lowercases to two code units, so folded-text offsets no longer match `text`. + guarded({ + type: "text_delta", + text: "note İ here\n<||DSML|| calls>\n<||DSML|| invoke name=\"exec\">private-body", + }); - expect(performance.now() - startedAt).toBeLessThan(1_000); - expect(events).toEqual([{ type: "text_delta", text: answer }]); + expect(events.at(-1)).toEqual(expect.objectContaining({ type: "error", code: "vendor_scaffold_detected" })); + expect(JSON.stringify(events)).not.toContain("private-body"); }); test("delivers quoted and inline-code DSML literals unchanged", () => {