mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-08 11:13:44 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The adapter utilities package provides transport code for sandbox agents > - The retired `duplex_v1` broker no longer produces or consumes body-chunk frames > - Dead protocol code remains in the host codec, gateway copy, bridge options, and tests > - This pull request removes that dead code and keeps the READY handshake unchanged > - The benefit is a smaller transport surface with fewer unused paths to maintain ## Linked Issues or Issue Description **What existing behavior does this improve?** This change improves the adapter utilities code that supports sandbox duplex readiness and frame handling. **Subsystem affected** `packages/adapter-utils/` — sandbox transport codecs, execution targets, and callback bridge tests. **Current behavior** The repository keeps body-chunk frame types, validators, a body spool, decoder limits, and tests after the `duplex_v1` broker removal. No live producer or consumer uses this code. **Proposed behavior** Remove the unused body-chunk protocol code and retain the READY handshake, its strict checks, and its size limits. **Reason and benefit** The removal reduces dead code and keeps the host and embedded gateway paths easier to inspect. It adds no new behavior. **Breaking changes** The removed frame types now decode as `unknown_type`. The live readiness gate already ignores those frames. The READY handshake stays byte-for-byte compatible. **Additional context** This cleanup follows [PR #12171](https://github.com/paperclipai/paperclip/pull/12171), which removed the duplex broker. ## What Changed - Remove `duplex-body-spool.ts` and its test. - Remove unused body-chunk frame types, validators, decoder code, vectors, and limits. - Remove the unused `reassembledBody` option and decoder limit environment entry. - Remove the embedded gateway decoder copy and the unused frame type map. - Keep the READY handshake and its existing boundary tests unchanged in behavior. ## Verification - `pnpm -F @paperclip/adapter-utils typecheck` passes. - The duplex frame codec test passes with 30 tests. - The sandbox execution-target test passes with 136 tests. - The sandbox callback bridge test passes with 46 tests. - CI must confirm all required checks after it starts. ## Risks Low risk. The change removes code only. The READY handshake, HTTP/2 body path, and byte-ledger path remain unchanged. ## Model Used OpenAI Codex, GPT-5, tool use and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
219 lines
8.1 KiB
TypeScript
219 lines
8.1 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
DEFAULT_MAX_DUPLEX_FRAME_BYTES,
|
|
DUPLEX_FRAME_VERSION,
|
|
decodeDuplexLine,
|
|
encodeDuplexFrame,
|
|
encodeDuplexFrameChecked,
|
|
type DuplexFrame,
|
|
} from "./duplex-frame-codec.js";
|
|
|
|
// One expected decode result in the fixture: either a decoded frame or the code
|
|
// of a protocol error. The codec must never throw on the read path.
|
|
type ExpectedResult = { frame: DuplexFrame } | { error: string };
|
|
|
|
interface Vector {
|
|
name: string;
|
|
category: "valid" | "invalid" | "versionMismatch";
|
|
bytes: string;
|
|
roundTrip?: boolean;
|
|
expected: ExpectedResult[];
|
|
}
|
|
|
|
// One encode-bound vector: a frame, the size limit, and the expected checked
|
|
// encode result. An ok result must also decode back to the same frame.
|
|
interface EncodeVector {
|
|
name: string;
|
|
maxFrameBytes: number;
|
|
frame: DuplexFrame;
|
|
expected: { ok: true } | { ok: false; error: string };
|
|
}
|
|
|
|
interface Fixture {
|
|
frameVersion: number;
|
|
defaultMaxFrameBytes: number;
|
|
vectors: Vector[];
|
|
encodeVectors: EncodeVector[];
|
|
}
|
|
|
|
const fixturePath = fileURLToPath(
|
|
new URL("./duplex-frame-vectors.json", import.meta.url),
|
|
);
|
|
const fixture = JSON.parse(readFileSync(fixturePath, "utf8")) as Fixture;
|
|
|
|
describe("duplex frame codec fixture", () => {
|
|
it("the fixture version matches the codec version", () => {
|
|
expect(fixture.frameVersion).toBe(DUPLEX_FRAME_VERSION);
|
|
expect(fixture.defaultMaxFrameBytes).toBe(DEFAULT_MAX_DUPLEX_FRAME_BYTES);
|
|
});
|
|
|
|
it("every category has at least one vector", () => {
|
|
const categories = new Set(fixture.vectors.map((vector) => vector.category));
|
|
for (const category of ["valid", "invalid", "versionMismatch"]) {
|
|
expect(categories).toContain(category);
|
|
}
|
|
});
|
|
|
|
for (const vector of fixture.vectors) {
|
|
it(`decodes the ${vector.name} vector`, () => {
|
|
// Every remaining vector is one complete line, so `decodeDuplexLine`
|
|
// reads it directly. The trailing newline in the fixture bytes is not
|
|
// part of the line the decoder reads.
|
|
const line = vector.bytes.endsWith("\n") ? vector.bytes.slice(0, -1) : vector.bytes;
|
|
const result = decodeDuplexLine(line);
|
|
const want = vector.expected[0];
|
|
if ("frame" in want) {
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) expect(result.frame).toEqual(want.frame);
|
|
} else {
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error.code).toBe(want.error);
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
describe("round-trip", () => {
|
|
const validVectors = fixture.vectors.filter((vector) => vector.roundTrip);
|
|
|
|
it("has round-trip vectors for every frame type", () => {
|
|
const types = new Set(
|
|
validVectors.flatMap((vector) =>
|
|
vector.expected.flatMap((result) =>
|
|
"frame" in result ? [result.frame.type] : [],
|
|
),
|
|
),
|
|
);
|
|
for (const type of ["ready", "heartbeat", "close", "error"]) {
|
|
expect(types).toContain(type);
|
|
}
|
|
});
|
|
|
|
for (const vector of validVectors) {
|
|
it(`re-encodes the ${vector.name} frame to the same value`, () => {
|
|
const want = vector.expected[0];
|
|
expect("frame" in want).toBe(true);
|
|
if (!("frame" in want)) return;
|
|
const encoded = encodeDuplexFrame(want.frame);
|
|
// Encode writes exactly one line: it ends with one newline and holds no
|
|
// interior newline, so one frame stays on one line.
|
|
expect(encoded.endsWith("\n")).toBe(true);
|
|
expect(encoded.slice(0, -1)).not.toContain("\n");
|
|
const decoded = decodeDuplexLine(encoded.slice(0, -1));
|
|
expect(decoded.ok).toBe(true);
|
|
if (decoded.ok) expect(decoded.frame).toEqual(want.frame);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe("ready frame schema", () => {
|
|
// READY is a liveness signal, not an address source. The strict schema holds
|
|
// exactly the frame version and the nonce.
|
|
it("accepts a READY frame that carries exactly the version and the nonce", () => {
|
|
const result = decodeDuplexLine(
|
|
JSON.stringify({ version: DUPLEX_FRAME_VERSION, type: "ready", nonce: "a1b2c3d4e5f6a7b8" }),
|
|
);
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) {
|
|
expect(result.frame).toEqual({
|
|
version: DUPLEX_FRAME_VERSION,
|
|
type: "ready",
|
|
nonce: "a1b2c3d4e5f6a7b8",
|
|
});
|
|
}
|
|
});
|
|
|
|
it.each([
|
|
{ name: "an absent nonce", frame: { version: DUPLEX_FRAME_VERSION, type: "ready" } },
|
|
{
|
|
name: "a wrong-typed nonce",
|
|
frame: { version: DUPLEX_FRAME_VERSION, type: "ready", nonce: 42 },
|
|
},
|
|
{
|
|
name: "an extra address field",
|
|
frame: {
|
|
version: DUPLEX_FRAME_VERSION,
|
|
type: "ready",
|
|
nonce: "a1b2c3d4e5f6a7b8",
|
|
address: "http://127.0.0.1:47215",
|
|
},
|
|
},
|
|
{
|
|
name: "an extra port field",
|
|
frame: { version: DUPLEX_FRAME_VERSION, type: "ready", nonce: "a1b2c3d4e5f6a7b8", port: 47215 },
|
|
},
|
|
])("rejects a READY frame with $name", ({ frame }) => {
|
|
const result = decodeDuplexLine(JSON.stringify(frame));
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error.code).toBe("malformed_frame");
|
|
});
|
|
});
|
|
|
|
describe("size-checked encode", () => {
|
|
it("runs every shared encode vector and matches the expected result", () => {
|
|
// Every codec copy runs the same encode vectors. This copy proves it enforces
|
|
// the same bound the embedded gateway copy enforces.
|
|
for (const vector of fixture.encodeVectors) {
|
|
const result = encodeDuplexFrameChecked(vector.frame, vector.maxFrameBytes);
|
|
expect(result.ok).toBe(vector.expected.ok);
|
|
if (result.ok) {
|
|
// An ok line ends with one newline and decodes back to the same frame, so
|
|
// the encode guard never truncates or passes a bad frame through.
|
|
expect(result.line.endsWith("\n")).toBe(true);
|
|
expect(result.line.slice(0, -1)).not.toContain("\n");
|
|
const decoded = decodeDuplexLine(result.line.slice(0, -1));
|
|
expect(decoded.ok).toBe(true);
|
|
if (decoded.ok) expect(decoded.frame).toEqual(vector.frame);
|
|
} else if (!vector.expected.ok) {
|
|
expect(result.error.code).toBe(vector.expected.error);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("rejects an over-bound frame with a typed outcome and never throws", () => {
|
|
const frame: DuplexFrame = {
|
|
version: DUPLEX_FRAME_VERSION,
|
|
type: "error",
|
|
code: "read_timeout",
|
|
message: "x".repeat(2_000),
|
|
};
|
|
expect(() => encodeDuplexFrameChecked(frame, 1_000)).not.toThrow();
|
|
const result = encodeDuplexFrameChecked(frame, 1_000);
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error.code).toBe("frame_too_large");
|
|
});
|
|
|
|
it("measures the bound in bytes without the trailing newline, so the boundary is inclusive", () => {
|
|
// Build a frame whose encoded JSON is exactly N bytes. The guard measures the
|
|
// JSON without the newline, so N bytes encodes and N+1 bytes fails. This is the
|
|
// same boundary the decoder applies, so encode and decode agree exactly.
|
|
// Pad the message to grow the encoded JSON by an exact byte count. Each
|
|
// padding character adds one byte, so the frame lands on an exact size the
|
|
// guard measures without the trailing newline.
|
|
const pad = (n: number): DuplexFrame => ({
|
|
version: DUPLEX_FRAME_VERSION,
|
|
type: "error",
|
|
code: "boundary",
|
|
message: "x".repeat(n),
|
|
});
|
|
const baseBytes = Buffer.byteLength(JSON.stringify(pad(0)), "utf8");
|
|
const limit = baseBytes + 10;
|
|
const atLimit = pad(10);
|
|
expect(Buffer.byteLength(JSON.stringify(atLimit), "utf8")).toBe(limit);
|
|
const okResult = encodeDuplexFrameChecked(atLimit, limit);
|
|
expect(okResult.ok).toBe(true);
|
|
const overResult = encodeDuplexFrameChecked(pad(11), limit);
|
|
expect(overResult.ok).toBe(false);
|
|
if (!overResult.ok) expect(overResult.error.code).toBe("frame_too_large");
|
|
});
|
|
|
|
it("defaults the bound to the default max frame bytes", () => {
|
|
const frame: DuplexFrame = { version: DUPLEX_FRAME_VERSION, type: "heartbeat" };
|
|
const result = encodeDuplexFrameChecked(frame);
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) expect(result.line).toBe(encodeDuplexFrame(frame));
|
|
});
|
|
});
|