Skip to content

Commit c12b68c

Browse files
committed
test(app-server): cover JSON-RPC dispatch end-to-end
Drives runAppServer through real PassThrough streams so the wire protocol is exercised exactly the way an IDE extension would speak to it. Tests cover server_ready emission, the initialize gate, model info reporting, prompt round-trip with agent events, get_state shape, concurrent prompt rejection, malformed-JSON parse errors, and the set_model unimplemented path. Adds a configOverride passthrough so the harness can inject a pi-ai faux provider — same pattern as runHeadless. Closes the last 0%-coverage integration path the audit flagged.
1 parent cd6b7cd commit c12b68c

2 files changed

Lines changed: 228 additions & 2 deletions

File tree

src/app-server/server.test.ts

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import { PassThrough } from "node:stream";
2+
import { fauxAssistantMessage, registerFauxProvider } from "@earendil-works/pi-ai";
3+
import type { Model } from "@earendil-works/pi-ai";
4+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
5+
import { runAppServer } from "./server.js";
6+
7+
/**
8+
* App-server tests drive the server through real stdin/stdout streams
9+
* (PassThrough) so the JSON-RPC wire protocol gets exercised end to
10+
* end. The pi-ai faux provider stands in for a real model — that lets
11+
* us assert on prompt() round trips without env vars.
12+
*/
13+
14+
type Outbound = { type: string } & Record<string, unknown>;
15+
16+
interface Harness {
17+
stdin: PassThrough;
18+
stdout: PassThrough;
19+
stderr: PassThrough;
20+
messages: Outbound[];
21+
donePromise: Promise<number>;
22+
send: (cmd: Record<string, unknown>) => void;
23+
waitFor: (predicate: (msg: Outbound) => boolean, timeoutMs?: number) => Promise<Outbound>;
24+
close: () => Promise<number>;
25+
}
26+
27+
function makeHarness(opts: { model: Model<string> }): Harness {
28+
const stdin = new PassThrough();
29+
const stdout = new PassThrough();
30+
const stderr = new PassThrough();
31+
const messages: Outbound[] = [];
32+
33+
let buffer = "";
34+
const listeners: Array<(msg: Outbound) => void> = [];
35+
stdout.on("data", (chunk: Buffer) => {
36+
buffer += chunk.toString("utf8");
37+
while (true) {
38+
const nl = buffer.indexOf("\n");
39+
if (nl === -1) break;
40+
const line = buffer.slice(0, nl).trim();
41+
buffer = buffer.slice(nl + 1);
42+
if (!line) continue;
43+
const msg = JSON.parse(line) as Outbound;
44+
messages.push(msg);
45+
for (const l of [...listeners]) l(msg);
46+
}
47+
});
48+
49+
const donePromise = runAppServer({
50+
stdin,
51+
stdout,
52+
stderr,
53+
autoApprove: true,
54+
configOverride: { model: opts.model, apiKey: "faux-key", source: "byok" },
55+
});
56+
57+
const send = (cmd: Record<string, unknown>): void => {
58+
stdin.write(`${JSON.stringify(cmd)}\n`);
59+
};
60+
61+
const waitFor = (predicate: (msg: Outbound) => boolean, timeoutMs = 2000): Promise<Outbound> => {
62+
const existing = messages.find(predicate);
63+
if (existing) return Promise.resolve(existing);
64+
return new Promise((resolve, reject) => {
65+
const timer = setTimeout(() => {
66+
listeners.splice(listeners.indexOf(handler), 1);
67+
reject(new Error("waitFor timed out"));
68+
}, timeoutMs);
69+
const handler = (msg: Outbound) => {
70+
if (!predicate(msg)) return;
71+
clearTimeout(timer);
72+
listeners.splice(listeners.indexOf(handler), 1);
73+
resolve(msg);
74+
};
75+
listeners.push(handler);
76+
});
77+
};
78+
79+
const close = async (): Promise<number> => {
80+
stdin.end();
81+
return donePromise;
82+
};
83+
84+
return { stdin, stdout, stderr, messages, donePromise, send, waitFor, close };
85+
}
86+
87+
describe("runAppServer", () => {
88+
let faux: ReturnType<typeof registerFauxProvider>;
89+
let model: Model<string>;
90+
91+
beforeEach(() => {
92+
faux = registerFauxProvider({
93+
models: [
94+
{
95+
id: "test-model",
96+
name: "Test Model",
97+
reasoning: false,
98+
input: ["text"],
99+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
100+
contextWindow: 100_000,
101+
maxTokens: 4096,
102+
},
103+
],
104+
tokenSize: { min: 1, max: 2 },
105+
});
106+
model = faux.models[0] as Model<string>;
107+
});
108+
109+
afterEach(() => {
110+
faux.unregister();
111+
});
112+
113+
it("emits server_ready on startup", async () => {
114+
const h = makeHarness({ model });
115+
const ready = await h.waitFor((m) => m.type === "event" && (m.event as { type: string }).type === "server_ready");
116+
expect(ready).toBeTruthy();
117+
await h.close();
118+
});
119+
120+
it("rejects commands before initialize", async () => {
121+
const h = makeHarness({ model });
122+
await h.waitFor((m) => m.type === "event" && (m.event as { type: string }).type === "server_ready");
123+
h.send({ id: "1", type: "get_state" });
124+
const err = await h.waitFor((m) => m.type === "response" && m.id === "1");
125+
expect(err.success).toBe(false);
126+
expect(err.error).toMatch(/initialize/i);
127+
await h.close();
128+
});
129+
130+
it("initializes successfully and returns model info", async () => {
131+
const h = makeHarness({ model });
132+
await h.waitFor((m) => m.type === "event" && (m.event as { type: string }).type === "server_ready");
133+
h.send({ id: "init", type: "initialize" });
134+
const resp = await h.waitFor((m) => m.type === "response" && m.id === "init");
135+
expect(resp.success).toBe(true);
136+
const data = resp.data as { model: { id: string }; source: string };
137+
expect(data.model.id).toBe("test-model");
138+
expect(data.source).toBe("byok");
139+
await h.close();
140+
});
141+
142+
it("routes a prompt through the agent and streams events back", async () => {
143+
faux.setResponses([fauxAssistantMessage("response from faux")]);
144+
const h = makeHarness({ model });
145+
await h.waitFor((m) => m.type === "event" && (m.event as { type: string }).type === "server_ready");
146+
h.send({ id: "init", type: "initialize" });
147+
await h.waitFor((m) => m.type === "response" && m.id === "init");
148+
h.send({ id: "p1", type: "prompt", message: "hello" });
149+
const ack = await h.waitFor((m) => m.type === "response" && m.id === "p1");
150+
expect(ack.success).toBe(true);
151+
// Wait for agent_end on the event stream.
152+
await h.waitFor(
153+
(m) => m.type === "event" && (m.event as { type: string }).type === "agent_end",
154+
5000,
155+
);
156+
// Some message_end events should have fired with our faux content.
157+
const messageEnds = h.messages.filter(
158+
(m) => m.type === "event" && (m.event as { type: string }).type === "message_end",
159+
);
160+
expect(messageEnds.length).toBeGreaterThan(0);
161+
await h.close();
162+
});
163+
164+
it("get_state reports cwd, model, status, message count", async () => {
165+
const h = makeHarness({ model });
166+
await h.waitFor((m) => m.type === "event" && (m.event as { type: string }).type === "server_ready");
167+
h.send({ id: "init", type: "initialize" });
168+
await h.waitFor((m) => m.type === "response" && m.id === "init");
169+
h.send({ id: "s", type: "get_state" });
170+
const resp = await h.waitFor((m) => m.type === "response" && m.id === "s");
171+
expect(resp.success).toBe(true);
172+
const data = resp.data as { status: string; cwd: string; model: { id: string }; messageCount: number };
173+
expect(data.status).toBe("idle");
174+
expect(typeof data.cwd).toBe("string");
175+
expect(data.model.id).toBe("test-model");
176+
expect(data.messageCount).toBe(0);
177+
await h.close();
178+
});
179+
180+
it("rejects a second prompt while one is in flight", async () => {
181+
faux.setResponses([fauxAssistantMessage("first response")]);
182+
const h = makeHarness({ model });
183+
await h.waitFor((m) => m.type === "event" && (m.event as { type: string }).type === "server_ready");
184+
h.send({ id: "init", type: "initialize" });
185+
await h.waitFor((m) => m.type === "response" && m.id === "init");
186+
h.send({ id: "p1", type: "prompt", message: "first" });
187+
await h.waitFor((m) => m.type === "response" && m.id === "p1");
188+
// Don't wait for agent_end — send a second prompt immediately.
189+
h.send({ id: "p2", type: "prompt", message: "second" });
190+
const resp = await h.waitFor((m) => m.type === "response" && m.id === "p2");
191+
expect(resp.success).toBe(false);
192+
expect(resp.error).toMatch(/in flight/i);
193+
await h.close();
194+
});
195+
196+
it("rejects malformed JSON with a parse error", async () => {
197+
const h = makeHarness({ model });
198+
await h.waitFor((m) => m.type === "event" && (m.event as { type: string }).type === "server_ready");
199+
h.stdin.write("not-json\n");
200+
const err = await h.waitFor((m) => m.type === "response" && m.command === "parse");
201+
expect(err.success).toBe(false);
202+
expect(err.error).toMatch(/parse/i);
203+
await h.close();
204+
});
205+
206+
it("set_model returns the not-yet-supported error", async () => {
207+
const h = makeHarness({ model });
208+
await h.waitFor((m) => m.type === "event" && (m.event as { type: string }).type === "server_ready");
209+
h.send({ id: "init", type: "initialize" });
210+
await h.waitFor((m) => m.type === "response" && m.id === "init");
211+
h.send({ id: "m", type: "set_model", provider: "anthropic", modelId: "claude" });
212+
const resp = await h.waitFor((m) => m.type === "response" && m.id === "m");
213+
expect(resp.success).toBe(false);
214+
expect(resp.error).toMatch(/not yet supported/i);
215+
await h.close();
216+
});
217+
});

src/app-server/server.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { createInterface } from "node:readline";
22
import type { Readable, Writable } from "node:stream";
33
import type { AgentEvent } from "@earendil-works/pi-agent-core";
44
import type { Usage } from "@earendil-works/pi-ai";
5-
import { type AgentBundle, createAgent } from "../agent/agent.js";
5+
import { type AgentBundle, type CreateAgentOptions, createAgent } from "../agent/agent.js";
66
import { ConfigError } from "../agent/config.js";
77
import type { PermissionRequest } from "../permissions/store.js";
88
import {
@@ -35,6 +35,11 @@ export interface AppServerOptions {
3535
resume?: boolean;
3636
/** When true, every tool call that would prompt the user auto-allows. */
3737
autoApprove?: boolean;
38+
/**
39+
* Test escape hatch — forwarded straight to createAgent so tests
40+
* can inject a pi-ai faux provider. Production never sets this.
41+
*/
42+
configOverride?: CreateAgentOptions["configOverride"];
3843
}
3944

4045
/**
@@ -60,7 +65,11 @@ export async function runAppServer(opts: AppServerOptions = {}): Promise<number>
6065

6166
let bundle: AgentBundle;
6267
try {
63-
bundle = createAgent({ resume: opts.resume, autoApprove: opts.autoApprove });
68+
bundle = createAgent({
69+
resume: opts.resume,
70+
autoApprove: opts.autoApprove,
71+
configOverride: opts.configOverride,
72+
});
6473
} catch (e) {
6574
const msg = e instanceof ConfigError ? e.message : e instanceof Error ? e.message : String(e);
6675
stderr.write(`app-server: ${msg}\n`);

0 commit comments

Comments
 (0)