mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
More slop
This commit is contained in:
parent
1ceb9ea488
commit
ba9339e8ca
17 changed files with 304 additions and 230 deletions
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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++) {
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue