Files
PaperClipAI/packages/adapter-utils/src/duplex-frame-codec.test.ts
T
Nicky LeachandPaperclip 802f2af154 refactor(adapter-utils): delete the dead duplex body-chunk protocol code (#12186)
## 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>
2026-08-25 14:33:38 -07:00

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