Skip to content

Commit 85d4c7f

Browse files
output-guard: the forwarded tail must stay assistant-terminal-equivalent (output slice of fork e0f8fcb)
Path-scoped slice of fork commit e0f8fcb: the guard's fifth invariant (assistant-terminal) and its tests. The same commit's insertion-normalization tail guard belongs to the insertion slice (cnighswonger#272); this slice carries the extension only as a sync, in the following commit. Co-Authored-By: Claude opus-5 <noreply@anthropic.com>
1 parent 2b222a2 commit 85d4c7f

2 files changed

Lines changed: 88 additions & 5 deletions

File tree

proxy/extensions/output-guard.mjs

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,12 +86,41 @@ function checkContentPresent(body) {
8686
return null;
8787
}
8888

89-
const VALIDATORS = [checkToolAdjacency, checkMarkerBudget, checkRoles, checkContentPresent];
89+
// Invariant 5 (BACKLOG.md, "suppression can strip a request's FINAL
90+
// message", 2026-07-30): a message-REMOVING mutation shipped (duplicate
91+
// suppression) without a tail-validity check, and three live requests
92+
// ended assistant-role -> upstream "400 must end with a user message".
93+
// The other four invariants are shape-level facts about ONE body; this
94+
// one needs the body CC actually sent, so it takes it as a second
95+
// argument rather than deriving anything from `body` alone.
96+
//
97+
// Conditioned on the INCOMING shape rather than an unconditional "never
98+
// end assistant": if CC itself sent a request already ending in
99+
// assistant role (a prefill-style continuation, however rare in observed
100+
// traffic), that is the client's own intent and not this guard's business
101+
// to overturn — the guard protects against OUR mutations, not against CC.
102+
// `incomingBody` absent (e.g. the pre-mutation stash unavailable, or a
103+
// direct unit-test call) means "cannot verify" for this one check, so it
104+
// yields no violation rather than guessing.
105+
function checkAssistantTerminal(body, incomingBody) {
106+
if (!incomingBody || !Array.isArray(incomingBody.messages) || incomingBody.messages.length === 0) return null;
107+
const incomingLast = incomingBody.messages[incomingBody.messages.length - 1];
108+
if (incomingLast?.role === "assistant") return null;
109+
const forwardedLast = body.messages[body.messages.length - 1];
110+
if (forwardedLast?.role === "assistant") {
111+
return "assistant-terminal: incoming request ended non-assistant but the forwarded body ends assistant — a mutation stripped the trailing message";
112+
}
113+
return null;
114+
}
115+
116+
const VALIDATORS = [checkToolAdjacency, checkMarkerBudget, checkRoles, checkContentPresent, checkAssistantTerminal];
90117

91-
// Exported for tests: run all validators, return the first violation or null.
92-
export function findViolation(body) {
118+
// Exported for tests: run all validators, return the first violation or
119+
// null. `incomingBody` is optional — only checkAssistantTerminal reads it;
120+
// every other validator is unaffected by its absence.
121+
export function findViolation(body, incomingBody) {
93122
for (const v of VALIDATORS) {
94-
const violation = v(body);
123+
const violation = v(body, incomingBody);
95124
if (violation) return violation;
96125
}
97126
return null;
@@ -122,7 +151,7 @@ export default {
122151
ctx.meta = ctx.meta || {};
123152
let violation;
124153
try {
125-
violation = findViolation(ctx.body);
154+
violation = findViolation(ctx.body, ctx.meta._preMutationBody);
126155
} catch (err) {
127156
// Cannot verify -> pass the mutated body through (fail-open); a
128157
// guard crash must never break the request or the pipeline's value.

test/output-guard.test.mjs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,60 @@ test("findViolation: invalid role and empty content named", () => {
100100
assert.match(findViolation(b2), /content: messages\[3\]/);
101101
});
102102

103+
// --- Invariant 5: assistant-terminal (BACKLOG.md, "suppression can strip
104+
// a request's FINAL message", 2026-07-30) ---
105+
106+
test("findViolation: healthy body, incoming also ends non-assistant -> null (no incomingBody = cannot verify, also null)", () => {
107+
const b = goodBody(); // ends on a tool_result (role user)
108+
assert.equal(findViolation(b, b), null);
109+
assert.equal(findViolation(b), null, "no incomingBody -> this check cannot fire");
110+
});
111+
112+
test("findViolation: incoming ended non-assistant but forwarded ends assistant -> assistant-terminal named", () => {
113+
const incoming = goodBody(); // last message role "user"
114+
const forwarded = goodBody();
115+
forwarded.messages.pop(); // simulate a mutation stripping the trailing tool_result
116+
assert.equal(forwarded.messages[forwarded.messages.length - 1].role, "assistant");
117+
assert.match(findViolation(forwarded, incoming), /assistant-terminal/);
118+
});
119+
120+
test("findViolation: incoming ITSELF ended assistant (prefill-shaped) -> not this guard's business, no violation", () => {
121+
const incoming = goodBody();
122+
incoming.messages.push({ role: "assistant", content: [{ type: "text", text: "partial" }] });
123+
const forwarded = structuredClone(incoming); // forwarded also ends assistant, matching CC's own intent
124+
assert.equal(findViolation(forwarded, incoming), null);
125+
});
126+
127+
test("findViolation: incoming and forwarded both end non-assistant -> null (healthy case)", () => {
128+
const incoming = goodBody();
129+
const forwarded = structuredClone(incoming);
130+
assert.equal(findViolation(forwarded, incoming), null);
131+
});
132+
133+
test("gate 2 (assistant-terminal): a mutator that strips the trailing message is caught, forwards the original, telemetry names it", async () => {
134+
await withGuardEnv(async (dir) => {
135+
const body = goodBody();
136+
const originalHash = sha(body);
137+
const ctx = { body, headers: { "x-session-id": "tail-strip-test" }, meta: { route: "messages" } };
138+
const stripTailMutator = {
139+
name: "test-strip-tail-mutator",
140+
order: 300,
141+
async onRequest(c) {
142+
c.body.messages.pop();
143+
},
144+
};
145+
await runOnRequest(ctx, [stash, stripTailMutator, guard]);
146+
147+
assert.equal(ctx.meta.outputGuardStats.fired, true);
148+
assert.equal(ctx.meta.outputGuardStats.restored, true);
149+
assert.match(ctx.meta.outputGuardStats.violation, /assistant-terminal/);
150+
assert.equal(sha(ctx.body), originalHash, "forwarded body is byte-identical to the pre-pipeline original");
151+
152+
const events = await readFile(join(dir, "cache-fix-snapshots", "s-tail-strip-test-guard-events.jsonl"), "utf-8");
153+
assert.match(events, /assistant-terminal/, "telemetry record names the violated invariant");
154+
});
155+
});
156+
103157
// --- Gate 1: zero fires on all healthy class corpora ---
104158

105159
// The corpus COUNT is deliberately not pinned. It was (`=== 8`), and adding a

0 commit comments

Comments
 (0)