mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
280 lines
10 KiB
TypeScript
280 lines
10 KiB
TypeScript
import { join } from "node:path";
|
|
import {
|
|
createReadTool,
|
|
type InMemorySessionCreateOptions,
|
|
InMemorySessionRepository,
|
|
type SessionMetadata,
|
|
type SessionRepository,
|
|
} from "@earendil-works/pi-agent-core";
|
|
import { fauxAssistantMessage } from "@earendil-works/pi-ai";
|
|
import type { TranscriptProgress } from "@earendil-works/pi-protocol";
|
|
import { describe, expect, test } from "vitest";
|
|
import { CodingAgentServerBackend } from "../../src/server/backend.ts";
|
|
import { createServerBackendFixture, removeServerBackendFixture } from "./fixture.ts";
|
|
|
|
function deferred(): { promise: Promise<void>; resolve(): void } {
|
|
let resolve = () => {};
|
|
const promise = new Promise<void>((resolvePromise) => {
|
|
resolve = resolvePromise;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
async function settlesBeforeNextTurn(promise: Promise<unknown>): Promise<boolean> {
|
|
return Promise.race([
|
|
promise.then(
|
|
() => true,
|
|
() => true,
|
|
),
|
|
new Promise<boolean>((resolve) => setImmediate(() => resolve(false))),
|
|
]);
|
|
}
|
|
|
|
describe("coding-agent server backend", () => {
|
|
test("normalizes snapshots, restores model/thinking, and holds an exclusive session lock", async () => {
|
|
const fixture = await createServerBackendFixture();
|
|
let runtime = await fixture.backend.createSession({
|
|
id: "server-session-1",
|
|
cwd: fixture.cwd,
|
|
name: "Backend test",
|
|
model: { provider: fixture.faux.provider.id, id: "faux-reasoning" },
|
|
thinkingLevel: "high",
|
|
});
|
|
try {
|
|
const initial = await runtime.snapshot();
|
|
expect(initial).toMatchObject({
|
|
id: "server-session-1",
|
|
name: "Backend test",
|
|
cwd: fixture.cwd,
|
|
phase: "idle",
|
|
model: { provider: fixture.faux.provider.id, id: "faux-reasoning" },
|
|
thinkingLevel: "high",
|
|
revision: 0,
|
|
transcript: [],
|
|
locked: true,
|
|
});
|
|
|
|
const models = await fixture.backend.listModels();
|
|
expect(models).toContainEqual(
|
|
expect.objectContaining({
|
|
provider: fixture.faux.provider.id,
|
|
id: "faux-reasoning",
|
|
authenticated: true,
|
|
supportedThinkingLevels: expect.arrayContaining(["off", "high"]),
|
|
}),
|
|
);
|
|
const summaries = await fixture.backend.listSessions();
|
|
expect(summaries).toContainEqual(
|
|
expect.objectContaining({ id: "server-session-1", name: "Backend test", locked: true }),
|
|
);
|
|
|
|
const secondBackend = await CodingAgentServerBackend.create({
|
|
defaultCwd: fixture.cwd,
|
|
sessionRoot: join(fixture.root, "sessions"),
|
|
modelRuntime: fixture.modelRuntime,
|
|
settingsManager: fixture.settingsManager,
|
|
});
|
|
await expect(secondBackend.openSession("server-session-1")).rejects.toMatchObject({
|
|
code: "session_locked",
|
|
});
|
|
|
|
fixture.faux.setResponses([fauxAssistantMessage("normalized response")]);
|
|
const progress: TranscriptProgress[] = [];
|
|
runtime.subscribe(() => {
|
|
throw new Error("network subscriber failed");
|
|
});
|
|
runtime.subscribe((event) => {
|
|
if (event.type === "progress") progress.push(event.progress);
|
|
});
|
|
await runtime.prompt({ text: "hello" });
|
|
const completed = await runtime.snapshot();
|
|
expect(completed.revision).toBeGreaterThan(initial.revision);
|
|
expect(completed.phase).toBe("idle");
|
|
expect(completed.transcript.map((item) => item.role)).toEqual(["user", "assistant"]);
|
|
expect(completed.transcript[1]).toMatchObject({
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "normalized response" }],
|
|
status: "complete",
|
|
stopReason: "stop",
|
|
});
|
|
expect(completed.transcript.some((item) => "parentId" in item)).toBe(false);
|
|
expect(progress.some((event) => event.type === "assistant_delta")).toBe(true);
|
|
|
|
await runtime.setModel({ provider: fixture.faux.provider.id, id: "faux-plain" });
|
|
expect(await runtime.snapshot()).toMatchObject({
|
|
model: { provider: fixture.faux.provider.id, id: "faux-plain" },
|
|
thinkingLevel: "off",
|
|
});
|
|
await runtime.setModel({ provider: fixture.faux.provider.id, id: "faux-reasoning" });
|
|
await runtime.setThinking("high");
|
|
|
|
await runtime.dispose();
|
|
const persistedSummary = (await secondBackend.listSessions()).find(
|
|
(summary) => summary.id === "server-session-1",
|
|
);
|
|
expect(persistedSummary).toBeDefined();
|
|
runtime = await secondBackend.openSession("server-session-1");
|
|
const restored = await runtime.snapshot();
|
|
expect(restored.updatedAt).toBe(persistedSummary?.updatedAt);
|
|
expect(restored.model).toEqual(initial.model);
|
|
expect(restored.thinkingLevel).toBe("high");
|
|
expect(restored.transcript).toEqual(completed.transcript);
|
|
} finally {
|
|
await runtime.dispose();
|
|
await removeServerBackendFixture(fixture);
|
|
}
|
|
});
|
|
test("does not expose sessions until initialization completes", async () => {
|
|
const fixture = await createServerBackendFixture();
|
|
const appendStarted = deferred();
|
|
const releaseAppend = deferred();
|
|
const sourceRepository = new InMemorySessionRepository();
|
|
let blockNextAppend = true;
|
|
const blockingRepository: SessionRepository<SessionMetadata, InMemorySessionCreateOptions> = {
|
|
async create(options) {
|
|
const session = await sourceRepository.create(options);
|
|
const appendCustomEntry = session.appendCustomEntry.bind(session);
|
|
session.appendCustomEntry = async (...args) => {
|
|
if (blockNextAppend) {
|
|
blockNextAppend = false;
|
|
appendStarted.resolve();
|
|
await releaseAppend.promise;
|
|
}
|
|
return appendCustomEntry(...args);
|
|
};
|
|
return session;
|
|
},
|
|
open: (metadata) => sourceRepository.open(metadata),
|
|
list: () => sourceRepository.list(),
|
|
delete: (metadata) => sourceRepository.delete(metadata),
|
|
fork: (source, options) => sourceRepository.fork(source, options),
|
|
[Symbol.asyncDispose]: () => sourceRepository[Symbol.asyncDispose](),
|
|
};
|
|
const backend = await CodingAgentServerBackend.create({
|
|
defaultCwd: fixture.cwd,
|
|
modelRuntime: fixture.modelRuntime,
|
|
settingsManager: fixture.settingsManager,
|
|
sessionRepository: blockingRepository,
|
|
createSessionOptions: ({ id }) => ({ id }),
|
|
lockRoot: join(fixture.root, "initialization-locks"),
|
|
});
|
|
let runtime: Awaited<ReturnType<typeof fixture.backend.createSession>> | undefined;
|
|
try {
|
|
const creating = backend.createSession({ id: "initializing-session", cwd: fixture.cwd });
|
|
await appendStarted.promise;
|
|
const listing = backend.listSessions();
|
|
|
|
expect(await settlesBeforeNextTurn(listing)).toBe(false);
|
|
releaseAppend.resolve();
|
|
runtime = await creating;
|
|
expect(await listing).toContainEqual(expect.objectContaining({ id: "initializing-session" }));
|
|
} finally {
|
|
releaseAppend.resolve();
|
|
await runtime?.dispose();
|
|
await sourceRepository[Symbol.asyncDispose]();
|
|
await removeServerBackendFixture(fixture);
|
|
}
|
|
});
|
|
|
|
test("builds its system prompt from harness tool metadata", async () => {
|
|
const fixture = await createServerBackendFixture();
|
|
const runtime = await fixture.backend.createSession({ id: "server-tool-prompt", cwd: fixture.cwd });
|
|
let systemPrompt: string | undefined;
|
|
fixture.faux.setResponses([
|
|
(context) => {
|
|
systemPrompt = context.systemPrompt;
|
|
return fauxAssistantMessage("ok");
|
|
},
|
|
]);
|
|
try {
|
|
await runtime.prompt({ text: "inspect tools" });
|
|
expect(systemPrompt).toContain("- read: Read file contents");
|
|
expect(systemPrompt).not.toContain(`- read: ${createReadTool().description}`);
|
|
expect(systemPrompt).toContain("Use read to examine files instead of cat or sed.");
|
|
expect(systemPrompt).toContain("Inspect PI_* environment variables for current model and session details.");
|
|
expect(systemPrompt?.indexOf("Inspect PI_* environment variables")).toBeLessThan(
|
|
systemPrompt?.indexOf("Use read to examine files") ?? -1,
|
|
);
|
|
} finally {
|
|
await runtime.dispose();
|
|
await removeServerBackendFixture(fixture);
|
|
}
|
|
});
|
|
|
|
test("uses an injected SessionRepository without depending on storage metadata", async () => {
|
|
const fixture = await createServerBackendFixture();
|
|
const sessionRepository = new InMemorySessionRepository();
|
|
const backend = await CodingAgentServerBackend.create({
|
|
defaultCwd: fixture.cwd,
|
|
modelRuntime: fixture.modelRuntime,
|
|
settingsManager: fixture.settingsManager,
|
|
sessionRepository,
|
|
createSessionOptions: ({ id }) => ({ id }),
|
|
lockRoot: join(fixture.root, "memory-locks"),
|
|
});
|
|
let runtime = await backend.createSession({ id: "memory-session", cwd: fixture.cwd });
|
|
try {
|
|
await runtime.dispose();
|
|
expect(await sessionRepository.list()).toEqual([
|
|
expect.not.objectContaining({ cwd: expect.anything(), path: expect.anything() }),
|
|
]);
|
|
expect(await backend.listSessions()).toContainEqual(
|
|
expect.objectContaining({ id: "memory-session", cwd: fixture.cwd }),
|
|
);
|
|
runtime = await backend.openSession("memory-session");
|
|
expect(await runtime.snapshot()).toMatchObject({ id: "memory-session", cwd: fixture.cwd });
|
|
} finally {
|
|
await runtime.dispose();
|
|
await sessionRepository[Symbol.asyncDispose]();
|
|
await removeServerBackendFixture(fixture);
|
|
}
|
|
});
|
|
|
|
test("applies server-only defaults to newly created sessions", async () => {
|
|
const fixture = await createServerBackendFixture();
|
|
fixture.backend.setDefaultSessionOptions({
|
|
model: { provider: fixture.faux.provider.id, id: "faux-plain" },
|
|
thinkingLevel: "off",
|
|
});
|
|
const runtime = await fixture.backend.createSession({ id: "server-defaults", cwd: fixture.cwd });
|
|
try {
|
|
expect(await runtime.snapshot()).toMatchObject({
|
|
model: { provider: fixture.faux.provider.id, id: "faux-plain" },
|
|
thinkingLevel: "off",
|
|
});
|
|
} finally {
|
|
await runtime.dispose();
|
|
await removeServerBackendFixture(fixture);
|
|
}
|
|
});
|
|
test("maps unsupported model and thinking requests to protocol-safe errors", async () => {
|
|
const fixture = await createServerBackendFixture();
|
|
try {
|
|
await expect(
|
|
fixture.backend.createSession({
|
|
id: "bad-model",
|
|
cwd: fixture.cwd,
|
|
model: { provider: "missing", id: "missing" },
|
|
}),
|
|
).rejects.toMatchObject({
|
|
name: "PiServerError",
|
|
code: "invalid_request",
|
|
message: expect.stringContaining("Could not resolve missing/missing"),
|
|
});
|
|
await expect(
|
|
fixture.backend.createSession({
|
|
id: "bad-thinking",
|
|
cwd: fixture.cwd,
|
|
model: { provider: fixture.faux.provider.id, id: "faux-reasoning" },
|
|
thinkingLevel: "max",
|
|
}),
|
|
).rejects.toMatchObject({
|
|
name: "PiServerError",
|
|
code: "invalid_request",
|
|
message: expect.stringContaining("not supported"),
|
|
});
|
|
} finally {
|
|
await removeServerBackendFixture(fixture);
|
|
}
|
|
});
|
|
});
|