mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
235 lines
7.5 KiB
TypeScript
235 lines
7.5 KiB
TypeScript
import { describe, expect, test } from "vitest";
|
|
import {
|
|
type ClientHello,
|
|
type ClientMessage,
|
|
ClientMessageDecoder,
|
|
decodeCbor,
|
|
encodeCbor,
|
|
encodeClientMessage,
|
|
encodeFrame,
|
|
encodeServerMessage,
|
|
FrameDecoder,
|
|
isSupportedProtocolVersion,
|
|
PROTOCOL_VERSION,
|
|
ProtocolValidationError,
|
|
parseClientMessage,
|
|
parseServerMessage,
|
|
type ServerHello,
|
|
type ServerMessage,
|
|
ServerMessageDecoder,
|
|
type ServerSnapshot,
|
|
} from "../src/index.ts";
|
|
|
|
const emptyServerSnapshot: ServerSnapshot = {
|
|
serverId: "server-1",
|
|
protocolVersion: PROTOCOL_VERSION,
|
|
revision: 0,
|
|
sessions: [],
|
|
models: [],
|
|
};
|
|
|
|
const clientHello: ClientHello = {
|
|
type: "hello",
|
|
version: PROTOCOL_VERSION,
|
|
token: "secret",
|
|
};
|
|
|
|
const serverHello: ServerHello = {
|
|
type: "hello",
|
|
version: PROTOCOL_VERSION,
|
|
connectionId: "connection-1",
|
|
snapshot: emptyServerSnapshot,
|
|
};
|
|
|
|
describe("protocol validation", () => {
|
|
test("uses protocol version 2", () => {
|
|
expect(PROTOCOL_VERSION).toBe(2);
|
|
expect(isSupportedProtocolVersion(2)).toBe(true);
|
|
expect(isSupportedProtocolVersion(1)).toBe(false);
|
|
expect(isSupportedProtocolVersion(2.5)).toBe(false);
|
|
});
|
|
|
|
test.each([0, 1, PROTOCOL_VERSION])("accepts integer client hello version %s for negotiation", (version) => {
|
|
const message = { ...clientHello, version };
|
|
expect(parseClientMessage(message)).toEqual(message);
|
|
});
|
|
|
|
test.each([
|
|
{ type: "hello", version: "2", token: "secret" },
|
|
{ type: "hello", version: 2.5, token: "secret" },
|
|
{ type: "hello", version: 2, token: "" },
|
|
{ type: "hello", version: 2, token: "secret", extra: true },
|
|
])("rejects an invalid strict handshake: $version", (message) => {
|
|
expect(() => parseClientMessage(message)).toThrow(ProtocolValidationError);
|
|
});
|
|
|
|
test("does not parse JSON strings as wire messages", () => {
|
|
expect(() => parseClientMessage(JSON.stringify(clientHello))).toThrow(ProtocolValidationError);
|
|
expect(() => parseServerMessage(JSON.stringify(serverHello))).toThrow(ProtocolValidationError);
|
|
});
|
|
|
|
test("rejects image input while the MVP remains text-only", () => {
|
|
expect(() =>
|
|
parseClientMessage({
|
|
type: "request",
|
|
id: "request-1",
|
|
request: {
|
|
command: "prompt",
|
|
sessionId: "session-1",
|
|
text: "inspect",
|
|
images: [{ type: "image", data: "abc", mimeType: "image/png" }],
|
|
},
|
|
}),
|
|
).toThrow(ProtocolValidationError);
|
|
});
|
|
|
|
test("parses a server handshake snapshot", () => {
|
|
expect(parseServerMessage(serverHello)).toEqual(serverHello);
|
|
});
|
|
|
|
test.each([
|
|
{ type: "hello", version: 1, connectionId: "connection-1", snapshot: emptyServerSnapshot },
|
|
{ type: "response", id: "request-1", ok: true, result: { command: "unknown" } },
|
|
{ type: "event", event: { type: "session_removed", sessionId: 42 } },
|
|
])("rejects invalid server messages", (wire) => {
|
|
expect(() => parseServerMessage(wire)).toThrow(ProtocolValidationError);
|
|
});
|
|
|
|
test("validates nested JSON tool details", () => {
|
|
const message = {
|
|
type: "event",
|
|
event: {
|
|
type: "session_progress",
|
|
sessionId: "session-1",
|
|
progress: {
|
|
type: "item_finished",
|
|
item: {
|
|
id: "tool-1",
|
|
role: "tool",
|
|
toolCallId: "call-1",
|
|
toolName: "read",
|
|
input: { path: "/tmp/file" },
|
|
content: [{ type: "text", text: "done" }],
|
|
details: { lines: [1, 2, 3], cached: false },
|
|
status: "complete",
|
|
isError: false,
|
|
timestamp: 1,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
expect(parseServerMessage(message)).toEqual(message);
|
|
});
|
|
|
|
test("validation errors do not retain rejected payloads", () => {
|
|
let thrown: unknown;
|
|
try {
|
|
parseClientMessage({ type: "hello", version: "2", token: "x".repeat(2_000_000) });
|
|
} catch (error) {
|
|
thrown = error;
|
|
}
|
|
expect(thrown).toBeInstanceOf(ProtocolValidationError);
|
|
expect(Object.hasOwn(thrown as object, "value")).toBe(false);
|
|
expect((thrown as Error).message.length).toBeLessThan(1_000);
|
|
});
|
|
});
|
|
|
|
describe("validated framed protocol APIs", () => {
|
|
test("encodes complete client and server frames", () => {
|
|
const clientFrames = new FrameDecoder().push(encodeClientMessage(clientHello));
|
|
expect(clientFrames).toHaveLength(1);
|
|
expect(parseClientMessage(decodeCbor(clientFrames[0]!))).toEqual(clientHello);
|
|
|
|
const serverFrames = new FrameDecoder().push(encodeServerMessage(serverHello));
|
|
expect(serverFrames).toHaveLength(1);
|
|
expect(parseServerMessage(decodeCbor(serverFrames[0]!))).toEqual(serverHello);
|
|
});
|
|
|
|
test("enforces an outbound frame limit before returning encoded bytes", () => {
|
|
expect(() => encodeClientMessage(clientHello, { maxFrameLength: 8 })).toThrow(ProtocolValidationError);
|
|
expect(() => encodeServerMessage(serverHello, { maxFrameLength: 8 })).toThrow(ProtocolValidationError);
|
|
});
|
|
|
|
test("validates messages before encoding", () => {
|
|
expect(() => encodeClientMessage({ type: "hello", version: 2, token: "" })).toThrow(ProtocolValidationError);
|
|
});
|
|
|
|
test("omits explicit undefined optional properties on the wire", () => {
|
|
const message: ClientMessage = {
|
|
type: "request",
|
|
id: "request-1",
|
|
request: { command: "create", cwd: undefined, name: undefined },
|
|
};
|
|
const [payload] = new FrameDecoder().push(encodeClientMessage(message));
|
|
expect(decodeCbor(payload!)).toEqual({
|
|
type: "request",
|
|
id: "request-1",
|
|
request: { command: "create" },
|
|
});
|
|
});
|
|
|
|
test("incrementally decodes fragmented and coalesced client messages", () => {
|
|
const request: ClientMessage = {
|
|
type: "request",
|
|
id: "request-1",
|
|
request: { command: "list" },
|
|
};
|
|
const first = encodeClientMessage(clientHello);
|
|
const second = encodeClientMessage(request);
|
|
const wire = new Uint8Array(first.byteLength + second.byteLength);
|
|
wire.set(first);
|
|
wire.set(second, first.byteLength);
|
|
|
|
for (let split = 0; split <= wire.byteLength; split++) {
|
|
const decoder = new ClientMessageDecoder();
|
|
const messages = [...decoder.push(wire.subarray(0, split)), ...decoder.push(wire.subarray(split))];
|
|
decoder.end();
|
|
expect(messages).toEqual([clientHello, request]);
|
|
}
|
|
});
|
|
|
|
test("incrementally decodes server messages", () => {
|
|
const errorMessage: ServerMessage = {
|
|
type: "hello_error",
|
|
error: { code: "auth", message: "Invalid token" },
|
|
};
|
|
const decoder = new ServerMessageDecoder();
|
|
expect(decoder.push(encodeServerMessage(errorMessage))).toEqual([errorMessage]);
|
|
decoder.end();
|
|
});
|
|
|
|
test.each([
|
|
["empty CBOR payload", encodeFrame(new Uint8Array())],
|
|
["malformed CBOR", encodeFrame(new Uint8Array([0xff]))],
|
|
["schema-invalid CBOR", encodeFrame(encodeCbor({ type: "hello", version: 2, token: "", extra: true }))],
|
|
] as const)("rejects invalid framed client input: %s", (_label, wire) => {
|
|
const decoder = new ClientMessageDecoder();
|
|
expect(() => decoder.push(wire)).toThrow(ProtocolValidationError);
|
|
expect(() => decoder.push(encodeClientMessage(clientHello))).toThrow(/failed/i);
|
|
});
|
|
|
|
test("rejects CBOR byte strings nested in JSON-valued fields", () => {
|
|
const wire = encodeFrame(
|
|
encodeCbor({
|
|
type: "response",
|
|
id: "request-1",
|
|
ok: false,
|
|
error: {
|
|
code: "invalid_request",
|
|
message: "invalid",
|
|
details: { nested: new Uint8Array([1, 2, 3]) },
|
|
},
|
|
}),
|
|
);
|
|
expect(() => new ServerMessageDecoder().push(wire)).toThrow(ProtocolValidationError);
|
|
});
|
|
|
|
test("rejects truncated and oversized framing through the validated decoder", () => {
|
|
const truncated = new ServerMessageDecoder();
|
|
expect(truncated.push(new Uint8Array([0, 0, 0, 2, 1]))).toEqual([]);
|
|
expect(() => truncated.end()).toThrow(ProtocolValidationError);
|
|
|
|
const oversized = new ClientMessageDecoder({ maxFrameLength: 3 });
|
|
expect(() => oversized.push(new Uint8Array([0, 0, 0, 4]))).toThrow(ProtocolValidationError);
|
|
});
|
|
});
|