From ba9339e8ca1b442ad9642c70f831a80c91efebee Mon Sep 17 00:00:00 2001 From: Christian Klotz Date: Sun, 2 Aug 2026 22:53:51 +0300 Subject: [PATCH] More slop --- .../agent/test/harness/agent-harness.test.ts | 34 ++++++++ packages/client/test/client.test.ts | 18 ++-- packages/client/test/unix.test.ts | 16 ++-- packages/coding-agent/CHANGELOG.md | 2 +- .../client-server/command-autocomplete.ts | 52 ++++++++++++ .../src/experimental/client-server/tui.ts | 62 ++++++-------- .../experimental-client-server-args.test.ts | 28 +++--- ...experimental-client-server-backend.test.ts | 85 +++++++++++-------- .../test/experimental-client-tui.test.ts | 37 ++++++++ .../experimental-client-view-model.test.ts | 6 ++ packages/protocol/test/cbor.test.ts | 7 +- packages/protocol/test/framing.test.ts | 13 +++ packages/protocol/test/protocol.test.ts | 5 +- packages/server/test/server.test.ts | 69 +-------------- packages/server/test/support.ts | 16 ++-- packages/server/test/transport-smoke.test.ts | 45 +--------- packages/server/test/unix-lifecycle.test.ts | 39 +++++++-- 17 files changed, 304 insertions(+), 230 deletions(-) create mode 100644 packages/coding-agent/src/experimental/client-server/command-autocomplete.ts diff --git a/packages/agent/test/harness/agent-harness.test.ts b/packages/agent/test/harness/agent-harness.test.ts index cf749bb51..7f86ffb12 100644 --- a/packages/agent/test/harness/agent-harness.test.ts +++ b/packages/agent/test/harness/agent-harness.test.ts @@ -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[] = []; diff --git a/packages/client/test/client.test.ts b/packages/client/test/client.test.ts index 7c2a11a8b..8b27a473a 100644 --- a/packages/client/test/client.test.ts +++ b/packages/client/test/client.test.ts @@ -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" }); }); }); diff --git a/packages/client/test/unix.test.ts b/packages/client/test/unix.test.ts index 648d88ad8..7cbf33511 100644 --- a/packages/client/test/unix.test.ts +++ b/packages/client/test/unix.test.ts @@ -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(); - 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(); @@ -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((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((resolve) => setImmediate(resolve)); + expect(closeCount).toBe(1); } finally { transport.close(); await closeServer(server, sockets); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 4f087988a..4fed1e22b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/experimental/client-server/command-autocomplete.ts b/packages/coding-agent/src/experimental/client-server/command-autocomplete.ts new file mode 100644 index 000000000..38aeca52c --- /dev/null +++ b/packages/coding-agent/src/experimental/client-server/command-autocomplete.ts @@ -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 { + 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; + } +} diff --git a/packages/coding-agent/src/experimental/client-server/tui.ts b/packages/coding-agent/src/experimental/client-server/tui.ts index 5e62f01f0..60bdca52e 100644 --- a/packages/coding-agent/src/experimental/client-server/tui.ts +++ b/packages/coding-agent/src/experimental/client-server/tui.ts @@ -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 { @@ -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 { - 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 { 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(); } diff --git a/packages/coding-agent/test/experimental-client-server-args.test.ts b/packages/coding-agent/test/experimental-client-server-args.test.ts index b6953ea56..56b283e48 100644 --- a/packages/coding-agent/test/experimental-client-server-args.test.ts +++ b/packages/coding-agent/test/experimental-client-server-args.test.ts @@ -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", () => { diff --git a/packages/coding-agent/test/experimental-client-server-backend.test.ts b/packages/coding-agent/test/experimental-client-server-backend.test.ts index c54f42dd7..31c307552 100644 --- a/packages/coding-agent/test/experimental-client-server-backend.test.ts +++ b/packages/coding-agent/test/experimental-client-server-backend.test.ts @@ -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 } { + let startedResolve: (() => void) | undefined; + const started = new Promise((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((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((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((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> = []; 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 => @@ -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 }); } diff --git a/packages/coding-agent/test/experimental-client-tui.test.ts b/packages/coding-agent/test/experimental-client-tui.test.ts index 0258916f9..cdeaec791 100644 --- a/packages/coding-agent/test/experimental-client-tui.test.ts +++ b/packages/coding-agent/test/experimental-client-tui.test.ts @@ -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); + }); +}); diff --git a/packages/coding-agent/test/experimental-client-view-model.test.ts b/packages/coding-agent/test/experimental-client-view-model.test.ts index da35e4bfb..2343dda34 100644 --- a/packages/coding-agent/test/experimental-client-view-model.test.ts +++ b/packages/coding-agent/test/experimental-client-view-model.test.ts @@ -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: { diff --git a/packages/protocol/test/cbor.test.ts b/packages/protocol/test/cbor.test.ts index b14161add..0bb944eed 100644 --- a/packages/protocol/test/cbor.test.ts +++ b/packages/protocol/test/cbor.test.ts @@ -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()], ] 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", () => { diff --git a/packages/protocol/test/framing.test.ts b/packages/protocol/test/framing.test.ts index 0cfd001f9..1cb893658 100644 --- a/packages/protocol/test/framing.test.ts +++ b/packages/protocol/test/framing.test.ts @@ -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++) { diff --git a/packages/protocol/test/protocol.test.ts b/packages/protocol/test/protocol.test.ts index 40346cb01..9b0d9c209 100644 --- a/packages/protocol/test/protocol.test.ts +++ b/packages/protocol/test/protocol.test.ts @@ -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([ diff --git a/packages/server/test/server.test.ts b/packages/server/test/server.test.ts index 4baf9fb84..de4a76580 100644 --- a/packages/server/test/server.test.ts +++ b/packages/server/test/server.test.ts @@ -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(); - }); }); diff --git a/packages/server/test/support.ts b/packages/server/test/support.ts index da2673472..ebddfd12c 100644 --- a/packages/server/test/support.ts +++ b/packages/server/test/support.ts @@ -256,6 +256,7 @@ export class WireClient { private readonly channel: WireChannel; private readonly decoder = new ServerMessageDecoder(); private readonly waiters = new Set(); + private readonly claimedMessages = new Set(); private readonly closedDeferred = new Deferred(); 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 { + async hello(token = TEST_TOKEN, version: number = PROTOCOL_VERSION): Promise { 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 { @@ -295,8 +296,11 @@ export class WireClient { } next(predicate: (message: ServerMessage) => boolean): Promise { - 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) { diff --git a/packages/server/test/transport-smoke.test.ts b/packages/server/test/transport-smoke.test.ts index 412cb6bb2..cf0ee0cef 100644 --- a/packages/server/test/transport-smoke.test.ts +++ b/packages/server/test/transport-smoke.test.ts @@ -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 { @@ -41,11 +33,9 @@ async function makeSocketPath(): Promise { } 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 { - 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); - }); -} diff --git a/packages/server/test/unix-lifecycle.test.ts b/packages/server/test/unix-lifecycle.test.ts index d0060e7ed..3e94d1388 100644 --- a/packages/server/test/unix-lifecycle.test.ts +++ b/packages/server/test/unix-lifecycle.test.ts @@ -18,6 +18,31 @@ async function makeSocketPath(nested = false): Promise { return nested ? join(directory, "p", "n", "server.sock") : join(directory, "server.sock"); } +async function waitForChildMessage(child: ChildProcess): Promise { + await new Promise((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" }); }); });