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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-03 17:17:42 -07:00
parent 18975c6ed6
commit 3de29d7279
3 changed files with 37 additions and 0 deletions

View File

@@ -1,4 +1,5 @@
---
category: architecture-patterns
module: fusion-plugin-acp-runtime
date: 2026-06-03
problem_type: architecture_pattern

View File

@@ -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);
});

View File

@@ -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);