repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
test/fixture/create-pi-session.ts
Raw// Repository-owned test fixture built only on public Pi APIs.
//
// It creates a real Pi session through `createAgentSession`, loads extension
// entrypoints through the real `DefaultResourceLoader`, and drives model turns
// with a deterministic scripted provider registered through the public
// `ModelRuntime.registerProvider`. It owns its temporary directories, a
// sandboxed environment (HOME, XDG_*, PI_CODING_AGENT_DIR), and shutdown, and
// collects extension load/bind errors so tests can fail on unexpected ones.
//
// Unlike the patched `@marcfargas/pi-test-harness`, this reaches no private
// agent internals (`streamFn`, `getApiKey`, `_modelRegistry`) and needs no
// install-time source patching.
//
// The sandboxed environment is applied to `process.env` for the session's
// lifetime and restored on `dispose`, so contract tests using this fixture must
// run in an isolated Vitest project (one file per worker process).
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
type CredentialStore,
createFauxCore,
type FauxProviderState,
type FauxResponseStep,
InMemoryModelsStore,
} from "@earendil-works/pi-ai";
import {
createAgentSession,
DefaultResourceLoader,
type Extension,
type ExtensionFactory,
ModelRuntime,
SessionManager,
} from "@earendil-works/pi-coding-agent";
const FAUX_PROVIDER = "faux";
const FAUX_MODEL = "faux-model";
export interface PiSessionOptions {
/** Absolute paths to extension entrypoints to load (path-loaded contract). */
extensionPaths?: string[];
/**
* In-process extension factories loaded in the test's own module graph, so
* `vi.mock`/`vi.spyOn` of an extension's internal modules is observable.
* Prefer `extensionPaths`; use factories only for component tests that must
* intercept an extension's own lazy imports.
*/
extensionFactories?: ExtensionFactory[];
/** Working directory; a temporary one is created and owned when omitted. */
cwd?: string;
/** User settings written to the isolated agent directory before load. */
userSettings?: Record<string, unknown>;
/** Credential storage override for tests that do not need file-backed auth. */
credentials?: CredentialStore;
/** Extra environment overrides applied on top of the sandbox. */
env?: Record<string, string | undefined>;
/** Initial scripted assistant responses for the deterministic provider. */
responses?: FauxResponseStep[];
}
export interface PiSession {
session: Awaited<ReturnType<typeof createAgentSession>>["session"];
/** Loaded extensions via the public loader API (never runner internals). */
extensions: Extension[];
cwd: string;
agentDir: string;
/** The sandboxed environment applied to `process.env` for this session. */
env: NodeJS.ProcessEnv;
/** Extension load, bind, and shutdown errors. */
errors: string[];
/** Replace the scripted assistant responses before the next turn. */
setResponses: (responses: FauxResponseStep[]) => void;
/** Deterministic provider call state, e.g. `callCount`. */
providerState: FauxProviderState;
dispose: () => Promise<void>;
}
function replaceProcessEnv(env: NodeJS.ProcessEnv): void {
for (const key of Object.keys(process.env)) delete process.env[key];
for (const [key, value] of Object.entries(env))
if (value !== undefined) process.env[key] = value;
}
export async function createPiSession(
options: PiSessionOptions,
): Promise<PiSession> {
const ownsCwd = !options.cwd;
const cwd = options.cwd ?? mkdtempSync(join(tmpdir(), "pi-fixture-"));
const agentDir = join(cwd, "agent");
mkdirSync(agentDir, { recursive: true });
if (options.userSettings)
writeFileSync(
join(agentDir, "settings.json"),
`${JSON.stringify(options.userSettings, null, "\t")}\n`,
);
const originalEnv = { ...process.env };
const env: NodeJS.ProcessEnv = {
...process.env,
HOME: join(cwd, ".home"),
USERPROFILE: join(cwd, ".home"),
APPDATA: options.env?.["XDG_CONFIG_HOME"] ?? join(cwd, ".config"),
LOCALAPPDATA: options.env?.["XDG_STATE_HOME"] ?? join(cwd, ".test-state"),
XDG_CONFIG_HOME: join(cwd, ".config"),
XDG_CACHE_HOME: join(cwd, ".cache"),
XDG_STATE_HOME: join(cwd, ".test-state"),
XDG_DATA_HOME: join(cwd, ".data"),
PI_CODING_AGENT_DIR: agentDir,
...options.env,
};
for (const [key, value] of Object.entries(options.env ?? {}))
if (value === undefined) delete env[key];
replaceProcessEnv(env);
const errors: string[] = [];
let disposed = false;
try {
const loader = new DefaultResourceLoader({
cwd,
agentDir,
noExtensions: true,
noSkills: true,
noPromptTemplates: true,
noThemes: true,
noContextFiles: true,
additionalExtensionPaths: options.extensionPaths ?? [],
...(options.extensionFactories
? { extensionFactories: options.extensionFactories }
: {}),
});
await loader.reload();
const loaded = loader.getExtensions();
errors.push(
...loaded.errors.map((error) => `${error.path}: ${error.error}`),
);
const core = createFauxCore({
api: "openai",
provider: FAUX_PROVIDER,
models: [{ id: FAUX_MODEL, name: "Faux Model" }],
});
if (options.responses) core.setResponses(options.responses);
const modelRuntime = await ModelRuntime.create({
...(options.credentials ? { credentials: options.credentials } : {}),
modelsStore: new InMemoryModelsStore(),
refreshOnCreate: false,
});
modelRuntime.registerProvider(FAUX_PROVIDER, {
apiKey: "test-key",
api: "openai",
baseUrl: "https://faux.invalid",
streamSimple: core.streamSimple,
models: [
{
id: FAUX_MODEL,
name: "Faux Model",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 8192,
},
],
});
const model = modelRuntime.getModel(FAUX_PROVIDER, FAUX_MODEL);
if (!model)
throw new Error("The owned fixture failed to register its model.");
const { session } = await createAgentSession({
cwd,
agentDir,
model,
modelRuntime,
resourceLoader: loader,
sessionManager: SessionManager.inMemory(cwd),
});
await session.bindExtensions({
mode: "print",
onError: (error) =>
errors.push(`${error.extensionPath}: ${error.event}: ${error.error}`),
});
return {
session,
extensions: loaded.extensions,
cwd,
agentDir,
env,
errors,
setResponses: core.setResponses,
providerState: core.state,
dispose: async () => {
if (disposed) return;
disposed = true;
const previousErrors = errors.length;
try {
await session.extensionRunner.emit({
type: "session_shutdown",
reason: "quit",
});
session.dispose();
await session.settingsManager.flush();
if (errors.length > previousErrors)
throw new Error(
`Fixture shutdown failed: ${errors.slice(previousErrors).join("; ")}`,
);
} finally {
replaceProcessEnv(originalEnv);
// Windows retains a child's cwd briefly after SDK shutdown returns.
if (ownsCwd)
await rm(cwd, {
recursive: true,
force: true,
maxRetries: 10,
retryDelay: 100,
});
}
},
};
} catch (error) {
replaceProcessEnv(originalEnv);
if (ownsCwd)
await rm(cwd, {
recursive: true,
force: true,
maxRetries: 10,
retryDelay: 100,
});
throw error;
}
}