More slop

This commit is contained in:
Christian Klotz 2026-08-02 22:53:51 +03:00
parent 1ceb9ea488
commit ba9339e8ca
No known key found for this signature in database
17 changed files with 304 additions and 230 deletions

View file

@ -113,6 +113,40 @@ describe("AgentHarness", () => {
expect(harness.getFollowUpMode()).toBe("one-at-a-time");
});
it("reports active turns and terminates without persisting a failure message", async () => {
const registration = newFaux();
const entered = deferred();
const release = deferred();
registration.setResponses([
async (_context, options) => {
entered.resolve();
await release.promise;
expect(options?.signal?.aborted).toBe(true);
return fauxAssistantMessage("must not persist");
},
]);
const session = new Session(new InMemorySessionStorage());
const harness = new AgentHarness({ models, session, model: registration.getModel() });
const prompt = harness.prompt("hello");
expect(harness.getPhase()).toBe("turn");
await entered.promise;
const terminalError = new Error("session lock lost");
harness.terminate(terminalError);
release.resolve();
await expect(prompt).rejects.toMatchObject({ name: "AgentHarnessError" });
expect(harness.getPhase()).toBe("idle");
await expect(harness.prompt("again")).rejects.toMatchObject({
name: "AgentHarnessError",
message: "session lock lost",
});
const messages = (await session.getEntries()).flatMap((entry) =>
entry.type === "message" ? [entry.message] : [],
);
expect(messages.map((message) => message.role)).not.toContain("assistant");
});
it("drains one queued steering message at a time and emits queue updates", async () => {
const registration = newFaux();
const userCounts: number[] = [];

View file

@ -18,7 +18,6 @@ import {
type ByteTransportHandlers,
PiClient,
PiDisconnectedError,
PiError,
PiSessionDetachedError,
} from "../src/index.ts";
@ -439,11 +438,15 @@ describe("PiClient", () => {
test("reports truncated framing when the transport closes", async () => {
const server = new MemoryByteServer();
const client = await connectClient(server);
const pending = client.listSessions();
server.sendRaw(new Uint8Array([0, 0, 0, 2, 1]));
server.close();
await expect(pending).rejects.toMatchObject({
name: "ProtocolValidationError",
message: expect.stringMatching(/truncated/i),
});
expect(client.connectionState).toBe("disconnected");
await expect(client.listSessions()).rejects.toBeInstanceOf(PiDisconnectedError);
});
test("rejects a mismatched response instead of leaving its request pending", async () => {
@ -451,14 +454,18 @@ describe("PiClient", () => {
const client = await connectClient(server);
const requests = collectRequests(server);
const listed = client.listSessions();
expect(requests).toMatchObject([{ request: { command: "list" } }]);
server.send({
type: "response",
id: requests[0]?.id ?? "missing",
id: requests[0]!.id,
ok: true,
result: { command: "attach", session: sessionSnapshot("session-1") },
});
await expect(listed).rejects.toBeInstanceOf(ProtocolValidationError);
await expect(listed).rejects.toMatchObject({
name: "ProtocolValidationError",
message: "Response command attach does not match list",
});
expect(client.connectionState).toBe("disconnected");
});
@ -584,7 +591,6 @@ describe("PiClient", () => {
ok: false,
error: { code: "session_locked", message: "Already attached" },
});
await expect(attaching).rejects.toBeInstanceOf(PiError);
await expect(attaching).rejects.toMatchObject({ code: "session_locked" });
await expect(attaching).rejects.toMatchObject({ name: "PiError", code: "session_locked" });
});
});

View file

@ -45,7 +45,6 @@ test("PiClient exchanges fragmented framed messages over a real Unix socket", as
const directory = await mkdtemp(join(tmpdir(), "pi-client-"));
const socketPath = join(directory, "pi.sock");
const sockets = new Set<Socket>();
const requestIds: string[] = [];
const server = createServer((socket) => {
sockets.add(socket);
socket.once("close", () => sockets.delete(socket));
@ -61,7 +60,6 @@ test("PiClient exchanges fragmented framed messages over a real Unix socket", as
});
for (const byte of hello) socket.write(new Uint8Array([byte]));
} else {
requestIds.push(message.id);
const response = encodeServerMessage({
type: "response",
id: message.id,
@ -84,7 +82,6 @@ test("PiClient exchanges fragmented framed messages over a real Unix socket", as
try {
await expect(client.connect()).resolves.toEqual(serverSnapshot);
await expect(Promise.all([client.listSessions(), client.listSessions()])).resolves.toEqual([[], []]);
expect(requestIds).toEqual(["request-1", "request-2"]);
} finally {
client.disconnect();
await closeServer(server, sockets);
@ -92,7 +89,7 @@ test("PiClient exchanges fragmented framed messages over a real Unix socket", as
}
});
test("Unix transport serializes backpressured writes and reports remote end once", async () => {
test("Unix transport bounds pending writes, preserves order, and reports remote end once", async () => {
const directory = await mkdtemp(join(tmpdir(), "pi-client-"));
const socketPath = join(directory, "pi.sock");
const sockets = new Set<Socket>();
@ -133,13 +130,17 @@ test("Unix transport serializes backpressured writes and reports remote end once
await listen(server, socketPath);
const inbound: number[] = [];
const errors: Error[] = [];
let closeCount = 0;
let resolveClosed: (() => void) | undefined;
const closed = new Promise<void>((resolve) => {
resolveClosed = resolve;
});
const transport = await createUnixTransportFactory({ path: socketPath })({
const transport = await createUnixTransportFactory({ path: socketPath, maxPendingBytes: expectedLength })({
onData: (chunk) => inbound.push(...chunk),
onClose: () => resolveClosed?.(),
onClose: () => {
closeCount += 1;
resolveClosed?.();
},
onError: (error) => errors.push(error),
});
@ -147,6 +148,7 @@ test("Unix transport serializes backpressured writes and reports remote end once
await serverReady;
const firstWrite = transport.send(first);
const secondWrite = transport.send(second);
await expect(transport.send(Uint8Array.of(3))).rejects.toThrow(/pending byte limit/);
resumeServer?.();
await Promise.all([firstWrite, secondWrite, received, closed]);
expect(receivedLength).toBe(expectedLength);
@ -154,6 +156,8 @@ test("Unix transport serializes backpressured writes and reports remote end once
expect(secondByteWrong).toBe(false);
expect(inbound).toEqual([9]);
expect(errors).toEqual([]);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(closeCount).toBe(1);
} finally {
transport.close();
await closeServer(server, sockets);

View file

@ -5,7 +5,7 @@
### Added
- Added `--alt` to run interactive mode with the alternate-screen TUI ([#7304](https://github.com/earendil-works/pi/issues/7304)).
- Added an experimental `PI_EXPERIMENTAL=1` client/server CLI with durable server-owned sessions, authenticated Unix socket transport, and a remote-session TUI with legacy message spacing, working activity, and double-Ctrl+C exit behavior.
- Added an experimental `PI_EXPERIMENTAL=1` client/server CLI with durable server-owned sessions, authenticated Unix socket transport, and a remote-session TUI with command autocomplete, legacy message spacing, working activity, and double-Ctrl+C exit behavior.
### Fixed

View file

@ -0,0 +1,52 @@
import {
type AutocompleteItem,
type AutocompleteProvider,
type AutocompleteSuggestions,
CombinedAutocompleteProvider,
type SlashCommand,
} from "@earendil-works/pi-tui";
import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts";
export const EXPERIMENTAL_SLASH_COMMANDS: readonly SlashCommand[] = [
...BUILTIN_SLASH_COMMANDS,
{ name: "reconnect", description: "Reconnect to the server" },
];
const EXPERIMENTAL_SLASH_COMMAND_NAMES = new Set(EXPERIMENTAL_SLASH_COMMANDS.map((command) => command.name));
export function isKnownExperimentalSlashCommand(name: string): boolean {
return EXPERIMENTAL_SLASH_COMMAND_NAMES.has(name);
}
export class SlashCommandAutocompleteProvider implements AutocompleteProvider {
private readonly delegate: CombinedAutocompleteProvider;
constructor(commands: readonly SlashCommand[]) {
this.delegate = new CombinedAutocompleteProvider([...commands], process.cwd());
}
getSuggestions(
lines: string[],
cursorLine: number,
cursorCol: number,
options: { signal: AbortSignal; force?: boolean },
): Promise<AutocompleteSuggestions | null> {
const textBeforeCursor = (lines[cursorLine] ?? "").slice(0, cursorCol);
if (cursorLine !== 0 || !textBeforeCursor.startsWith("/")) return Promise.resolve(null);
return this.delegate.getSuggestions(lines, cursorLine, cursorCol, { ...options, force: false });
}
applyCompletion(
lines: string[],
cursorLine: number,
cursorCol: number,
item: AutocompleteItem,
prefix: string,
): { lines: string[]; cursorLine: number; cursorCol: number } {
return this.delegate.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
}
shouldTriggerFileCompletion(): boolean {
return false;
}
}

View file

@ -4,11 +4,18 @@ import type {
JsonValue,
ModelMetadata,
SessionSummary,
ThinkingLevel,
ToolTranscriptItem,
TranscriptItem,
} from "@earendil-works/pi-protocol";
import { Container, ProcessTerminal, Spacer, setKeybindings, Text, TUI } from "@earendil-works/pi-tui";
import {
Container,
ProcessTerminal,
Spacer,
setKeybindings,
Text,
type TUI,
TuiMainScreen,
} from "@earendil-works/pi-tui";
import { getAgentDir } from "../../config.ts";
import { KeybindingsManager } from "../../core/keybindings.ts";
import type { SettingsManager } from "../../core/settings-manager.ts";
@ -20,6 +27,11 @@ import { ToolExecutionComponent } from "../../modes/interactive/components/tool-
import { UserMessageComponent } from "../../modes/interactive/components/user-message.ts";
import { getEditorTheme, initTheme, stopThemeWatcher, theme } from "../../modes/interactive/theme/theme.ts";
import type { ExperimentalClientController, ExperimentalClientView } from "./client-controller.ts";
import {
EXPERIMENTAL_SLASH_COMMANDS,
isKnownExperimentalSlashCommand,
SlashCommandAutocompleteProvider,
} from "./command-autocomplete.ts";
export class DoubleClearAction {
private lastTriggeredAt?: number;
@ -128,12 +140,13 @@ export class ExperimentalClientTui {
try {
const keybindings = KeybindingsManager.create();
setKeybindings(keybindings);
this.ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor(), getAgentDir());
this.ui = new TuiMainScreen(new ProcessTerminal(), settingsManager.getShowHardwareCursor(), getAgentDir());
this.ui.setClearOnShrink(settingsManager.getClearOnShrink());
this.editor = new CustomEditor(this.ui, getEditorTheme(), keybindings, {
paddingX: settingsManager.getEditorPaddingX(),
autocompleteMaxVisible: settingsManager.getAutocompleteMaxVisible(),
});
this.editor.setAutocompleteProvider(new SlashCommandAutocompleteProvider(EXPERIMENTAL_SLASH_COMMANDS));
this.editor.onSubmit = (text) => void this.handleSubmit(text);
this.editor.onAction("app.interrupt", () => void this.handleInterrupt());
this.editor.onAction("app.clear", () => this.handleClear());
@ -318,21 +331,21 @@ export class ExperimentalClientTui {
else this.showModelSelector();
return;
}
if (command === "/thinking") {
if (argument) await this.selectThinkingByName(argument);
else this.showThinkingSelector();
return;
}
if (command === "/reconnect") {
await this.reconnect();
return;
}
if (command === "/quit" || command === "/exit") {
if (command === "/quit") {
await this.shutdown();
return;
}
this.setStatus(`Unknown command: ${command}`, true);
this.setStatus(
isKnownExperimentalSlashCommand(command.slice(1))
? `Command ${command} is not implemented in experimental mode`
: `Unknown command: ${command}`,
true,
);
}
private async reconnect(): Promise<void> {
@ -434,30 +447,6 @@ export class ExperimentalClientTui {
}
}
private showThinkingSelector(): void {
if (!this.requireIdle("change thinking level")) return;
const model = this.currentModel();
if (!model) return;
this.showSelector("Select thinking level", [...model.supportedThinkingLevels], async (label) => {
await this.controller.setThinking(label as ThinkingLevel);
this.setStatus(`Thinking: ${label}`);
});
}
private async selectThinkingByName(name: string): Promise<void> {
if (!this.requireIdle("change thinking level")) return;
const model = this.currentModel();
if (!model?.supportedThinkingLevels.includes(name as ThinkingLevel)) {
this.setStatus(`Unsupported thinking level: ${name}`, true);
return;
}
try {
await this.controller.setThinking(name as ThinkingLevel);
} catch (error) {
this.showError(error);
}
}
private async cycleThinking(): Promise<void> {
if (!this.requireIdle("change thinking level")) return;
const model = this.currentModel();
@ -509,7 +498,10 @@ export class ExperimentalClientTui {
this.clearWorkingStatus();
this.status.clear();
this.statusMessage = message ? new Text(theme.fg(error ? "error" : "muted", message), 1, 0) : undefined;
if (this.statusMessage) this.status.addChild(this.statusMessage);
if (this.statusMessage) {
this.status.addChild(new Spacer(1));
this.status.addChild(this.statusMessage);
}
this.syncWorkingStatus();
this.ui.requestRender();
}

View file

@ -66,24 +66,22 @@ describe("experimental client/server CLI parser", () => {
] as const)("rejects unsupported input %j", (argv, message) => {
const result = parseExperimentalCliArgs(argv);
expect(result.args).toBeUndefined();
expect(result.errors.some((error) => error.includes(message))).toBe(true);
expect(result.errors).toContainEqual(expect.stringContaining(message));
});
test("allows server model defaults but rejects client-only session selection and prompts", () => {
const result = parseExperimentalCliArgs([
"--server",
"/tmp/pi.sock",
"--provider",
"faux",
"--model",
"one",
"--session",
"old",
"hello",
]);
test("allows model defaults in server mode", () => {
const result = parseExperimentalCliArgs(["--server", "/tmp/pi.sock", "--provider", "faux", "--model", "one"]);
expect(result.errors).toEqual([]);
expect(result.args).toMatchObject({ role: "server", provider: "faux", model: "one" });
});
test("rejects client-only session selection and prompts in server mode", () => {
const result = parseExperimentalCliArgs(["--server", "/tmp/pi.sock", "--session", "old", "hello"]);
expect(result.args).toBeUndefined();
expect(result.errors).toContain("An initial prompt is only valid for client or combined roles");
expect(result.errors).toContain("--session is only valid for client or combined roles");
expect(result.errors).toEqual([
"--session is only valid for client or combined roles",
"An initial prompt is only valid for client or combined roles",
]);
});
test("validates socket paths, required values, and thinking levels", () => {

View file

@ -2,11 +2,11 @@ import { randomUUID } from "node:crypto";
import { appendFile, mkdir, mkdtemp, readdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fauxAssistantMessage, fauxProvider, fauxToolCall } from "@earendil-works/pi-ai";
import { type FauxResponseFactory, fauxAssistantMessage, fauxProvider, fauxToolCall } from "@earendil-works/pi-ai";
import { PiClient } from "@earendil-works/pi-client";
import { createUnixTransportFactory } from "@earendil-works/pi-client/unix";
import type { TranscriptProgress } from "@earendil-works/pi-protocol";
import { PiServer, PiServerError } from "@earendil-works/pi-server";
import { PiServer } from "@earendil-works/pi-server";
import lockfile, { type LockOptions } from "proper-lockfile";
import { describe, expect, test, vi } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.ts";
@ -51,6 +51,25 @@ async function createFixture() {
return { root, cwd, faux, modelRuntime, settingsManager, backend };
}
function abortableResponse(): { response: FauxResponseFactory; started: Promise<void> } {
let startedResolve: (() => void) | undefined;
const started = new Promise<void>((resolve) => {
startedResolve = resolve;
});
return {
started,
response: async (_context, options) => {
startedResolve?.();
const signal = options?.signal;
if (!signal) throw new Error("Expected an abort signal");
if (!signal.aborted) {
await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
}
return fauxAssistantMessage("aborted");
},
};
}
describe("experimental AgentHarness server backend", () => {
test("normalizes snapshots, restores model/thinking, and holds an exclusive session lock", async () => {
const fixture = await createFixture();
@ -218,16 +237,11 @@ describe("experimental AgentHarness server backend", () => {
cwd: fixture.cwd,
model: { provider: fixture.faux.provider.id, id: "faux-reasoning" },
});
fixture.faux.setResponses([fauxAssistantMessage("long response ".repeat(1_000))]);
let startedResolve: (() => void) | undefined;
const started = new Promise<void>((resolvePromise) => {
startedResolve = resolvePromise;
});
runtime.subscribe(() => {
if (runtime?.getPhase() === "turn") startedResolve?.();
});
const pendingResponse = abortableResponse();
fixture.faux.setResponses([pendingResponse.response]);
const prompt = runtime.prompt({ text: "start" });
await started;
expect(runtime.getPhase()).toBe("turn");
await pendingResponse.started;
await forceRelease?.();
compromise?.(new Error("lock ownership lost"));
@ -286,19 +300,11 @@ describe("experimental AgentHarness server backend", () => {
model: { provider: fixture.faux.provider.id, id: "faux-reasoning" },
});
try {
fixture.faux.setResponses([fauxAssistantMessage("long response ".repeat(1_000))]);
let resolveTurn: (() => void) | undefined;
const turnStarted = new Promise<void>((resolvePromise) => {
resolveTurn = resolvePromise;
});
const unsubscribe = runtime.subscribe((event) => {
if (event.type !== "snapshot") return;
void Promise.resolve(runtime.snapshot()).then((snapshot) => {
if (snapshot.phase === "turn") resolveTurn?.();
});
});
const pendingResponse = abortableResponse();
fixture.faux.setResponses([pendingResponse.response]);
const prompt = runtime.prompt({ text: "start" });
await turnStarted;
expect(runtime.getPhase()).toBe("turn");
await pendingResponse.started;
await runtime.steer({ text: "adjust the approach" });
expect(await runtime.snapshot()).toMatchObject({
queuedSteerCount: 1,
@ -306,7 +312,6 @@ describe("experimental AgentHarness server backend", () => {
});
await runtime.abort();
await prompt;
unsubscribe();
} finally {
await runtime.dispose();
await rm(fixture.root, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
@ -331,7 +336,7 @@ describe("experimental AgentHarness server backend", () => {
}
});
test("streams live bash output and keeps runtime revisions monotonic", async () => {
test("streams live bash output into progress and the final snapshot", async () => {
const fixture = await createFixture();
const runtime = await fixture.backend.createSession({
id: "server-session-bash",
@ -348,17 +353,10 @@ describe("experimental AgentHarness server backend", () => {
fauxAssistantMessage("done"),
]);
const progress: TranscriptProgress[] = [];
const revisionReads: Array<Promise<number>> = [];
runtime.subscribe((event) => {
if (event.type === "progress") progress.push(event.progress);
if (event.type === "snapshot") {
revisionReads.push(Promise.resolve(runtime.snapshot()).then((snapshot) => snapshot.revision));
}
});
await runtime.prompt({ text: "run it" });
const revisions = await Promise.all(revisionReads);
expect(revisions.length).toBeGreaterThan(2);
expect(revisions).toEqual([...revisions].sort((left, right) => left - right));
const toolUpdates = progress.filter(
(event): event is Extract<TranscriptProgress, { type: "item_updated" }> =>
@ -379,6 +377,10 @@ describe("experimental AgentHarness server backend", () => {
status: "complete",
isError: false,
});
if (!tool || tool.role !== "tool") throw new Error("Expected tool transcript item");
const output = tool.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("");
expect(output).toContain("first");
expect(output).toContain("second");
} finally {
await runtime.dispose();
await rm(fixture.root, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
@ -390,12 +392,13 @@ describe("experimental AgentHarness server backend", () => {
const token = "transport-secret";
const socketPath = join(fixture.root, "server.sock");
const server = new PiServer(fixture.backend, { token, unix: { path: socketPath } });
await server.start();
const client = new PiClient({
token,
transportFactory: createUnixTransportFactory({ path: socketPath }),
});
let controller: ExperimentalClientController | undefined;
try {
await server.start();
const serverSnapshot = await client.connect();
expect(serverSnapshot.models).toContainEqual(
expect.objectContaining({ provider: fixture.faux.provider.id, id: "faux-reasoning" }),
@ -406,7 +409,7 @@ describe("experimental AgentHarness server backend", () => {
thinkingLevel: "medium",
});
expect(session.id).toMatch(/^[0-9a-f-]{36}$/);
const controller = new ExperimentalClientController(client);
controller = new ExperimentalClientController(client);
await controller.attachInitial(session);
fixture.faux.setResponses([fauxAssistantMessage("over unix")]);
const progress: TranscriptProgress[] = [];
@ -431,8 +434,8 @@ describe("experimental AgentHarness server backend", () => {
expect(client.isSessionAttached(previousId)).toBe(false);
const reopened = await fixture.backend.openSession(previousId);
await reopened.dispose();
await controller.dispose();
} finally {
await controller?.dispose();
client.disconnect();
await server.close();
await rm(fixture.root, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
@ -448,7 +451,11 @@ describe("experimental AgentHarness server backend", () => {
cwd: fixture.cwd,
model: { provider: "missing", id: "missing" },
}),
).rejects.toBeInstanceOf(PiServerError);
).rejects.toMatchObject({
name: "PiServerError",
code: "invalid_request",
message: expect.stringContaining("Could not resolve missing/missing"),
});
await expect(
fixture.backend.createSession({
id: "bad-thinking",
@ -456,7 +463,11 @@ describe("experimental AgentHarness server backend", () => {
model: { provider: fixture.faux.provider.id, id: "faux-reasoning" },
thinkingLevel: "max",
}),
).rejects.toMatchObject({ code: "invalid_request" });
).rejects.toMatchObject({
name: "PiServerError",
code: "invalid_request",
message: expect.stringContaining("not supported"),
});
} finally {
await rm(fixture.root, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
}

View file

@ -1,4 +1,10 @@
import { describe, expect, test } from "vitest";
import { BUILTIN_SLASH_COMMANDS } from "../src/core/slash-commands.ts";
import {
EXPERIMENTAL_SLASH_COMMANDS,
isKnownExperimentalSlashCommand,
SlashCommandAutocompleteProvider,
} from "../src/experimental/client-server/command-autocomplete.ts";
import { DoubleClearAction } from "../src/experimental/client-server/tui.ts";
describe("experimental client TUI", () => {
@ -7,6 +13,7 @@ describe("experimental client TUI", () => {
expect(action.trigger(1_000)).toBe("clear");
expect(action.trigger(1_499)).toBe("exit");
expect(action.trigger(1_500)).toBe("clear");
});
test("starts a new double-clear window after the timeout", () => {
@ -17,3 +24,33 @@ describe("experimental client TUI", () => {
expect(action.trigger(1_999)).toBe("exit");
});
});
describe("experimental client command autocomplete", () => {
test("shows every legacy and experimental command when slash is typed", async () => {
const provider = new SlashCommandAutocompleteProvider(EXPERIMENTAL_SLASH_COMMANDS);
const suggestions = await provider.getSuggestions(["/"], 0, 1, { signal: new AbortController().signal });
expect(suggestions?.prefix).toBe("/");
expect(suggestions?.items.map((item) => item.value)).toEqual([
...BUILTIN_SLASH_COMMANDS.map((command) => command.name),
"reconnect",
]);
expect(suggestions?.items.map((item) => item.value)).not.toContain("exit");
expect(isKnownExperimentalSlashCommand("tree")).toBe(true);
expect(isKnownExperimentalSlashCommand("made-up")).toBe(false);
});
test("filters commands without enabling filesystem completion", async () => {
const provider = new SlashCommandAutocompleteProvider(EXPERIMENTAL_SLASH_COMMANDS);
const signal = new AbortController().signal;
expect((await provider.getSuggestions(["/rec"], 0, 4, { signal }))?.items.map((item) => item.value)).toEqual([
"reconnect",
]);
expect(
(await provider.getSuggestions(["/rec"], 0, 4, { signal, force: true }))?.items.map((item) => item.value),
).toEqual(["reconnect"]);
expect(await provider.getSuggestions(["README"], 0, 6, { signal, force: true })).toBeNull();
expect(provider.shouldTriggerFileCompletion()).toBe(false);
});
});

View file

@ -107,6 +107,12 @@ describe("experimental client view model", () => {
timestamp: 2,
},
});
expect(model.view()?.transcript.at(-1)).toMatchObject({
id: "tool-call-1",
role: "tool",
status: "running",
content: [],
});
model.applyProgress({
type: "item_updated",
item: {

View file

@ -1,5 +1,6 @@
import { describe, expect, test } from "vitest";
import {
CborError,
DEFAULT_MAX_CBOR_BYTE_LENGTH,
DEFAULT_MAX_CBOR_CONTAINER_LENGTH,
DEFAULT_MAX_CBOR_DEPTH,
@ -95,13 +96,13 @@ describe("CBOR codec", () => {
["Date", new Date(0)],
["Map", new Map<string, unknown>()],
] as const)("rejects unsupported encoder value: %s", (_label, value) => {
expect(() => encodeCbor(value)).toThrow();
expect(() => encodeCbor(value)).toThrow(CborError);
});
test("rejects maps with enumerable symbol keys", () => {
const value = { valid: true };
Object.defineProperty(value, Symbol("key"), { enumerable: true, value: false });
expect(() => encodeCbor(value)).toThrow();
expect(() => encodeCbor(value)).toThrow(CborError);
});
test("rejects lossy strings, cycles, and excessive encoder depth", () => {
@ -147,7 +148,7 @@ describe("CBOR codec", () => {
["unsafe negative integer", "3b001fffffffffffff"],
["unsafe integer encoded as float64", "fb4340000000000000"],
] as const)("rejects invalid decoder input: %s", (_label, wire) => {
expect(() => decodeCbor(fromHex(wire))).toThrow();
expect(() => decodeCbor(fromHex(wire))).toThrow(CborError);
});
test("enforces depth and declared length limits before traversing values", () => {

View file

@ -43,6 +43,19 @@ describe("binary framing", () => {
coalesced.end();
});
test("assembles payloads spanning multiple internal blocks", () => {
const payload = Uint8Array.from({ length: 70_000 }, (_, index) => index % 251);
const wire = encodeFrame(payload);
const decoder = new FrameDecoder();
const frames = [
...decoder.push(wire.subarray(0, 101)),
...decoder.push(wire.subarray(101, 65_541)),
...decoder.push(wire.subarray(65_541)),
];
decoder.end();
expect(frames).toEqual([payload]);
});
test("handles every split point across a frame", () => {
const wire = encodeFrame(new Uint8Array([10, 20, 30, 40]));
for (let split = 0; split <= wire.byteLength; split++) {

View file

@ -49,8 +49,9 @@ describe("protocol validation", () => {
expect(isSupportedProtocolVersion(2.5)).toBe(false);
});
test("accepts an integer version and bearer token in the client hello", () => {
expect(parseClientMessage(clientHello)).toEqual(clientHello);
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([

View file

@ -395,36 +395,6 @@ afterEach(async () => {
});
describe("PiServer Unix integration", () => {
test("requires a valid first-message auth/version handshake", async () => {
const backend = new MemoryBackend();
backend.seed();
const { server } = await startServer(backend);
const badToken = await connect(server);
const authError = await badToken.hello("wrong");
expect(authError).toMatchObject({ type: "hello_error", error: { code: "auth" } });
await once(badToken.socket, "close");
const badVersion = await connect(server);
const versionError = await badVersion.hello(TOKEN, PROTOCOL_VERSION + 1);
expect(versionError).toMatchObject({ type: "hello_error", error: { code: "version" } });
const client = await connect(server);
const hello = await client.hello();
expect(hello).toMatchObject({
type: "hello",
version: PROTOCOL_VERSION,
snapshot: { sessions: [{ id: "session-1" }], models: [MODEL] },
});
});
test("times out clients that do not send a hello", async () => {
const { server } = await startServer(new MemoryBackend(), { handshakeTimeoutMs: 10 });
const client = await connect(server);
await once(client.socket, "close");
expect(client.socket.destroyed).toBe(true);
});
test("creates server-assigned durable IDs and supports list, attach, and detach", async () => {
const { server, backend } = await startServer();
const client = await connect(server);
@ -454,27 +424,6 @@ describe("PiServer Unix integration", () => {
expect(backend.runtimes.get(backend.lastCreatedId!)?.length).toBe(2);
});
test("keeps multiple attachments on one connection independent", async () => {
const backend = new MemoryBackend();
backend.seed("first");
backend.seed("second");
const { server } = await startServer(backend);
const client = await connect(server);
await client.hello();
await attach(client, "first");
await attach(client, "second");
await client.request({ command: "detach", sessionId: "first" });
expect(backend.latestRuntime("first").disposeCount).toBe(1);
expect(backend.latestRuntime("second").disposeCount).toBe(0);
const response = await client.request({
command: "set_thinking",
sessionId: "second",
thinkingLevel: "medium",
});
expect(response).toMatchObject({ ok: true, result: { session: { id: "second", thinkingLevel: "medium" } } });
});
test("broadcasts full snapshots and progress only to clients attached to that session", async () => {
const backend = new MemoryBackend();
backend.seed();
@ -500,6 +449,7 @@ describe("PiServer Unix integration", () => {
type: "event",
event: { type: "session_progress", sessionId: "session-1", progress },
});
await unattachedClient.request({ command: "list" });
expect(
unattachedClient.messages.some(
(message) => message.type === "event" && message.event.type === "session_progress",
@ -519,6 +469,7 @@ describe("PiServer Unix integration", () => {
type: "event",
event: { type: "session_snapshot", snapshot: { id: "session-1", attached: true, locked: true } },
});
await unattachedClient.request({ command: "list" });
expect(
unattachedClient.messages.some(
(message) => message.type === "event" && message.event.type === "session_snapshot",
@ -691,20 +642,4 @@ describe("PiServer Unix integration", () => {
const unattached = await client.request({ command: "abort", sessionId: "locked" });
expect(unattached).toMatchObject({ ok: false, error: { code: "invalid_request" } });
});
test("gracefully closes clients and releases all live runtimes", async () => {
const backend = new MemoryBackend();
backend.seed();
const { server } = await startServer(backend);
const client = await connect(server);
await client.hello();
await attach(client, "session-1");
const runtime = backend.latestRuntime("session-1");
const clientClosed = once(client.socket, "close");
await server.close();
await clientClosed;
expect(runtime.disposeCount).toBe(1);
expect(server.unixSocketPath).toBeUndefined();
await server.close();
});
});

View file

@ -256,6 +256,7 @@ export class WireClient {
private readonly channel: WireChannel;
private readonly decoder = new ServerMessageDecoder();
private readonly waiters = new Set<MessageWaiter>();
private readonly claimedMessages = new Set<ServerMessage>();
private readonly closedDeferred = new Deferred<void>();
private requestSequence = 0;
private closedValue = false;
@ -268,10 +269,10 @@ export class WireClient {
return this.closedValue;
}
hello(token = TEST_TOKEN, version: number = PROTOCOL_VERSION): Promise<ServerMessage> {
async hello(token = TEST_TOKEN, version: number = PROTOCOL_VERSION): Promise<ServerMessage> {
const response = this.next((message) => message.type === "hello" || message.type === "hello_error");
void this.sendMessage({ type: "hello", token, version });
return response;
await this.sendMessage({ type: "hello", token, version });
return await response;
}
async request(command: Command, id = `request-${++this.requestSequence}`): Promise<ResponseEnvelope> {
@ -295,8 +296,11 @@ export class WireClient {
}
next(predicate: (message: ServerMessage) => boolean): Promise<ServerMessage> {
const existing = this.messages.find(predicate);
if (existing) return Promise.resolve(existing);
const existing = this.messages.find((message) => !this.claimedMessages.has(message) && predicate(message));
if (existing) {
this.claimedMessages.add(existing);
return Promise.resolve(existing);
}
if (this.closedValue) return Promise.reject(new Error("Wire client is closed"));
return new Promise((resolve, reject) => this.waiters.add({ predicate, resolve, reject }));
}
@ -316,7 +320,9 @@ export class WireClient {
for (const waiter of this.waiters) {
if (!waiter.predicate(message)) continue;
this.waiters.delete(waiter);
this.claimedMessages.add(message);
waiter.resolve(message);
break;
}
}
} catch (error) {

View file

@ -1,14 +1,7 @@
import { once } from "node:events";
import { lstat, mkdtemp, rm } from "node:fs/promises";
import { createConnection, type Socket } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
encodeClientMessage,
PROTOCOL_VERSION,
type ServerMessage,
ServerMessageDecoder,
} from "@earendil-works/pi-protocol";
import { ServerMessageDecoder } from "@earendil-works/pi-protocol";
import { afterEach, expect, test, vi } from "vitest";
import type { ByteConnection } from "../src/connection.ts";
import { PiServerCore } from "../src/core.ts";
@ -32,7 +25,6 @@ const backend: PiSessionBackend = {
};
let server: PiServer | undefined;
let socket: Socket | undefined;
let tempDirectory: string | undefined;
async function makeSocketPath(): Promise<string> {
@ -41,11 +33,9 @@ async function makeSocketPath(): Promise<string> {
}
afterEach(async () => {
socket?.destroy();
await server?.close();
if (tempDirectory) await rm(tempDirectory, { recursive: true, force: true });
server = undefined;
socket = undefined;
tempDirectory = undefined;
});
@ -66,21 +56,6 @@ test("rejects an overlong derived private Unix bind path", async () => {
await expect(server.start()).rejects.toThrow(/private Unix bind path.*too long/);
});
test("Unix socket accepts a fragmented framed-CBOR hello", async () => {
const path = await makeSocketPath();
server = new PiServer(backend, { token: TOKEN, unix: { path } });
await server.start();
expect(server.unixSocketPath).toBe(path);
socket = createConnection(path);
await once(socket, "connect");
const response = nextServerMessage(socket);
const hello = encodeClientMessage({ type: "hello", version: PROTOCOL_VERSION, token: TOKEN });
socket.write(hello.subarray(0, 2));
socket.write(hello.subarray(2));
expect(await response).toMatchObject({ type: "hello", version: PROTOCOL_VERSION });
});
test("rejects concurrent start calls without leaking the Unix listener", async () => {
const path = await makeSocketPath();
server = new PiServer(backend, { token: TOKEN, unix: { path } });
@ -137,21 +112,3 @@ test("rejects pending-byte limits smaller than one maximum frame", async () => {
() => new PiServer(backend, { token: TOKEN, unix: { path }, maxFrameLength: 128, maxPendingBytes: 131 }),
).toThrow(/maxPendingBytes/);
});
function nextServerMessage(target: Socket): Promise<ServerMessage> {
const decoder = new ServerMessageDecoder();
return new Promise((resolve, reject) => {
const onData = (chunk: Buffer): void => {
try {
const messages = decoder.push(new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength));
if (messages.length === 0) return;
target.off("data", onData);
resolve(messages[0]!);
} catch (error) {
reject(error);
}
};
target.on("data", onData);
target.once("error", reject);
});
}

View file

@ -18,6 +18,31 @@ async function makeSocketPath(nested = false): Promise<string> {
return nested ? join(directory, "p", "n", "server.sock") : join(directory, "server.sock");
}
async function waitForChildMessage(child: ChildProcess): Promise<void> {
await new Promise<void>((resolve, reject) => {
const cleanup = (): void => {
child.off("message", onMessage);
child.off("error", onError);
child.off("exit", onExit);
};
const onMessage = (): void => {
cleanup();
resolve();
};
const onError = (error: Error): void => {
cleanup();
reject(error);
};
const onExit = (code: number | null, signal: NodeJS.Signals | null): void => {
cleanup();
reject(new Error(`Stale-socket fixture exited before readiness: code=${code}, signal=${signal}`));
};
child.once("message", onMessage);
child.once("error", onError);
child.once("exit", onExit);
});
}
function makeServer(path: string): PiServer {
const server = new PiServer(new ConformanceBackend(), { token: TEST_TOKEN, unix: { path } });
servers.add(server);
@ -98,20 +123,16 @@ describe("Unix listener filesystem lifecycle", () => {
stdio: ["ignore", "ignore", "inherit", "ipc"],
});
children.add(child);
await once(child, "message");
const staleIdentity = await lstat(path);
expect(staleIdentity.isSocket()).toBe(true);
await waitForChildMessage(child);
expect((await lstat(path)).isSocket()).toBe(true);
child.kill("SIGKILL");
await once(child, "exit");
children.delete(child);
const server = makeServer(path);
await server.start();
const liveIdentity = await lstat(path);
expect(liveIdentity.isSocket()).toBe(true);
expect({ dev: liveIdentity.dev, ino: liveIdentity.ino }).not.toEqual({
dev: staleIdentity.dev,
ino: staleIdentity.ino,
});
const client = await connectUnix(path);
clients.add(client);
expect(await client.hello()).toMatchObject({ type: "hello" });
});
});