From 3de29d7279aa42b61db7dd1a3ca8fb838b2ca00f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 3 Jun 2026 17:17:42 -0700 Subject: [PATCH] fix(acp): plan-only streams enforce the per-turn cap; add category frontmatter handlePlan charged the budget but never checked the ceiling or set the flag, so a plan-ONLY stream kept emitting after crossing the cap (caught by both review bots). It now flags + truncates exactly like text/thinking. Adds the plan-only flood regression test (185 total) and the category frontmatter field to the new solutions doc. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...stent-jsonrpc-agent-runtime-integration.md | 1 + .../src/__tests__/event-bridge-bounds.test.ts | 26 +++++++++++++++++++ .../src/event-bridge.ts | 10 +++++++ 3 files changed, 37 insertions(+) diff --git a/docs/solutions/architecture-patterns/acp-persistent-jsonrpc-agent-runtime-integration.md b/docs/solutions/architecture-patterns/acp-persistent-jsonrpc-agent-runtime-integration.md index 61bf2687bc..4a12845a97 100644 --- a/docs/solutions/architecture-patterns/acp-persistent-jsonrpc-agent-runtime-integration.md +++ b/docs/solutions/architecture-patterns/acp-persistent-jsonrpc-agent-runtime-integration.md @@ -1,4 +1,5 @@ --- +category: architecture-patterns module: fusion-plugin-acp-runtime date: 2026-06-03 problem_type: architecture_pattern diff --git a/plugins/fusion-plugin-acp-runtime/src/__tests__/event-bridge-bounds.test.ts b/plugins/fusion-plugin-acp-runtime/src/__tests__/event-bridge-bounds.test.ts index 7d8a1d6bf4..1c089ebcfa 100644 --- a/plugins/fusion-plugin-acp-runtime/src/__tests__/event-bridge-bounds.test.ts +++ b/plugins/fusion-plugin-acp-runtime/src/__tests__/event-bridge-bounds.test.ts @@ -202,3 +202,29 @@ describe("plan output bounds (S5)", () => { expect(thinking.length).toBe(before); }); }); + + it("a plan-ONLY stream stops emitting once the per-turn cap is crossed", async () => { + const { createEventBridge, PER_CHUNK_CAP_CHARS, PER_TURN_OUTPUT_CAP_CHARS, MAX_PLAN_ENTRIES } = + await import("../event-bridge.js"); + const thinking: string[] = []; + const bridge = createEventBridge({ onThinking: (t) => thinking.push(t) }); + // Each plan line is bounded by PER_CHUNK_CAP_CHARS; flood plan events only. + const bigEntry = "p".repeat(PER_CHUNK_CAP_CHARS); + const entries = Array.from({ length: MAX_PLAN_ENTRIES }, () => ({ + content: bigEntry, + priority: "low", + status: "pending", + })); + const floods = Math.ceil(PER_TURN_OUTPUT_CAP_CHARS / PER_CHUNK_CAP_CHARS) + 3; + for (let i = 0; i < floods; i += 1) { + bridge.handleSessionUpdate({ sessionUpdate: "plan", entries } as never); + } + // The flag line is emitted exactly once, then nothing further. + const flagged = thinking.filter((t) => t.includes("output truncated")); + expect(flagged).toHaveLength(1); + const after = thinking.length; + bridge.handleSessionUpdate({ sessionUpdate: "plan", entries } as never); + expect(thinking.length).toBe(after); + // And the total emitted is bounded near the cap, not floods * cap. + expect(thinking.length).toBeLessThan(floods); + }); diff --git a/plugins/fusion-plugin-acp-runtime/src/event-bridge.ts b/plugins/fusion-plugin-acp-runtime/src/event-bridge.ts index 543f663ba6..94d41a42b8 100644 --- a/plugins/fusion-plugin-acp-runtime/src/event-bridge.ts +++ b/plugins/fusion-plugin-acp-runtime/src/event-bridge.ts @@ -235,6 +235,16 @@ export function createEventBridge(callbacks: AcpCallbacks): EventBridge { // agent-controlled — without the cap below, one plan event with thousands // of entries bypasses the per-turn ceiling entirely. if (outputCapFlagged) return; + // Enforce the ceiling on the plan path too: without this check a plan-ONLY + // stream (no text/thinking ever entering forwardBounded) would keep + // emitting forever after crossing the budget. + if (cumulativeOutputChars >= PER_TURN_OUTPUT_CAP_CHARS) { + outputCapFlagged = true; + callbacks.onThinking?.( + "[output truncated: per-turn limit reached — further agent output suppressed]", + ); + return; + } const list = Array.isArray(entries) ? entries : []; const capped = list.slice(0, MAX_PLAN_ENTRIES); let line = formatPlan(capped);