Skip to content

Commit bfef732

Browse files
committed
mason: tolerate omitted subc response text
1 parent 3f8c82b commit bfef732

3 files changed

Lines changed: 113 additions & 8 deletions

File tree

crates/aft/src/subc/wire.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,7 @@ struct ToolResponseEnvelope<'a> {
424424
// reply without `structuredContent.text`, so a module-first rollout breaks
425425
// every installed plugin). Collapsing it safely means emitting both shapes,
426426
// waiting for plugins to update, then dropping one behind a version floor.
427+
// The bridge now accepts replies that omit `structuredContent.text`, and the module may omit that field after the minimum supported plugin version includes this compatibility behavior.
427428
//
428429
// Measured on a live daemon: the largest real frames were ~200 KB, with zero
429430
// egress-write time, writer queue depth 1, never full, and no reserve

packages/aft-bridge/src/__tests__/subc-transport.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,33 @@ function envelope(flat: Record<string, unknown>): Record<string, unknown> {
168168
};
169169
}
170170

171+
// This fixture preserves the field order from a real read response: the Rust wire
172+
// serializer emits `text` as the final structured field.
173+
const CAPTURED_FIRST_PARTY_READ_ENVELOPE: Record<string, unknown> = {
174+
content: [
175+
{
176+
type: "text",
177+
text: "export function readFixture(): string { return 'captured'; }\n",
178+
},
179+
],
180+
isError: false,
181+
structuredContent: {
182+
id: "read-fixture-1",
183+
success: true,
184+
status_bar: { errors: 0, warnings: 1 },
185+
bg_completions: [{ task_id: "bash-fixture-1" }],
186+
text: "export function readFixture(): string { return 'captured'; }\n",
187+
},
188+
};
189+
190+
function withoutStructuredText(envelopeResponse: Record<string, unknown>): Record<string, unknown> {
191+
const structuredContent = {
192+
...(envelopeResponse.structuredContent as Record<string, unknown>),
193+
};
194+
delete structuredContent.text;
195+
return { ...envelopeResponse, structuredContent };
196+
}
197+
171198
describe("SubcTransport.toolCall", () => {
172199
test("sends {name, arguments} and re-lifts structuredContent to the flat result", async () => {
173200
const client = new FakeClient(async () =>
@@ -436,6 +463,68 @@ describe("SubcTransport Rd reconnect", () => {
436463
});
437464

438465
describe("SubcTransport reply envelope (B-#7)", () => {
466+
test("synthesizes missing structuredContent.text from a single outer text block", async () => {
467+
const duplicatedClient = new FakeClient(async () => CAPTURED_FIRST_PARTY_READ_ENVELOPE);
468+
const { pool: duplicatedPool } = poolWith(duplicatedClient);
469+
const duplicated = await duplicatedPool
470+
.getBridge("/work/proj")
471+
.toolCall("sess-1", "read", { filePath: "fixture.ts" });
472+
473+
const synthesizedClient = new FakeClient(async () =>
474+
withoutStructuredText(CAPTURED_FIRST_PARTY_READ_ENVELOPE),
475+
);
476+
const { pool: synthesizedPool } = poolWith(synthesizedClient);
477+
const synthesized = await synthesizedPool
478+
.getBridge("/work/proj")
479+
.toolCall("sess-1", "read", { filePath: "fixture.ts" });
480+
481+
const capturedBytes = new TextEncoder().encode(
482+
JSON.stringify(CAPTURED_FIRST_PARTY_READ_ENVELOPE.structuredContent),
483+
);
484+
expect(new TextEncoder().encode(JSON.stringify(duplicated))).toEqual(capturedBytes);
485+
expect(new TextEncoder().encode(JSON.stringify(synthesized))).toEqual(capturedBytes);
486+
});
487+
488+
test("present structuredContent.text wins without inspecting outer content", async () => {
489+
const response = {
490+
...CAPTURED_FIRST_PARTY_READ_ENVELOPE,
491+
content: [{ type: "image", data: "not inspected" }],
492+
};
493+
const client = new FakeClient(async () => response);
494+
const { pool } = poolWith(client);
495+
496+
const result = await pool.getBridge("/work/proj").toolCall("sess-1", "read", {});
497+
498+
expect(new TextEncoder().encode(JSON.stringify(result))).toEqual(
499+
new TextEncoder().encode(
500+
JSON.stringify(CAPTURED_FIRST_PARTY_READ_ENVELOPE.structuredContent),
501+
),
502+
);
503+
});
504+
505+
test.each([
506+
["zero blocks", []],
507+
[
508+
"multiple blocks",
509+
[
510+
{ type: "text", text: "first" },
511+
{ type: "text", text: "second" },
512+
],
513+
],
514+
["non-text block", [{ type: "image", data: "not text" }]],
515+
])("missing structuredContent.text with %s stays fail-closed", async (_name, content) => {
516+
const client = new FakeClient(async () => ({
517+
content,
518+
isError: false,
519+
structuredContent: { id: "read-1", success: true },
520+
}));
521+
const { pool } = poolWith(client);
522+
523+
await expect(pool.getBridge("/work/proj").toolCall("sess-1", "read", {})).rejects.toThrow(
524+
"subc tool reply structuredContent lacks a boolean `success` / string `text` (protocol violation)",
525+
);
526+
});
527+
439528
test("a reply missing the structuredContent envelope throws (protocol violation)", async () => {
440529
// No structuredContent → must NOT be coerced to a silent {success:false}.
441530
const client = new FakeClient(async () => ({ content: [], isError: false }));

packages/aft-bridge/src/subc-transport.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -351,13 +351,16 @@ class RouteTornDownError extends Error {}
351351
* `structuredContent`, so re-lifting it makes everything downstream (status_bar,
352352
* bg_completions, preview_diff, code, …) byte-identical to NDJSON.
353353
*
354-
* Every AFT tool reply over subc carries this envelope with a boolean `success`
355-
* and string `text`. A reply missing the envelope, or whose lifted shape lacks a
356-
* boolean `success`, is a PROTOCOL VIOLATION — never a tool result — and is thrown
357-
* rather than coerced. Coercing it (the old `{success:false,text:""}` /
358-
* raw-record fallback) could let a malformed reply with `success === undefined`
359-
* read downstream as a successful tool result (audit B-#7). Surfacing it loudly is
360-
* the honest contract: a broken wire shape is a failure, not a silent empty pass.
354+
* Every AFT tool reply over subc carries this envelope with a boolean `success`.
355+
* During the wire transition, `structuredContent.text` may be omitted; the bridge
356+
* synthesizes it only from exactly one outer text block, while a present field wins
357+
* unchanged. A reply missing the envelope, lacking boolean `success`, or missing
358+
* text without an unambiguous outer block is a PROTOCOL VIOLATION — never a tool
359+
* result — and is thrown rather than coerced. Coercing it (the old
360+
* `{success:false,text:""}` / raw-record fallback) could let a malformed reply with
361+
* `success === undefined` read downstream as a successful tool result (audit B-#7).
362+
* Surfacing it loudly is the honest contract: a broken wire shape is a failure, not
363+
* a silent empty pass.
361364
*/
362365
function reliftReply(reply: unknown): Record<string, unknown> {
363366
if (!isRecord(reply) || !isRecord(reply.structuredContent)) {
@@ -366,7 +369,19 @@ function reliftReply(reply: unknown): Record<string, unknown> {
366369
);
367370
}
368371
const flat = reply.structuredContent;
369-
if (typeof flat.success !== "boolean" || typeof flat.text !== "string") {
372+
if (typeof flat.success !== "boolean") {
373+
throw new Error(
374+
"subc tool reply structuredContent lacks a boolean `success` / string `text` (protocol violation)",
375+
);
376+
}
377+
if (!Object.hasOwn(flat, "text")) {
378+
const content = reply.content;
379+
const block = Array.isArray(content) && content.length === 1 ? content[0] : undefined;
380+
if (isRecord(block) && block.type === "text" && typeof block.text === "string") {
381+
return { ...flat, text: block.text };
382+
}
383+
}
384+
if (typeof flat.text !== "string") {
370385
throw new Error(
371386
"subc tool reply structuredContent lacks a boolean `success` / string `text` (protocol violation)",
372387
);

0 commit comments

Comments
 (0)