Files
pi/packages/coding-agent/test/agent-session-branching.test.ts
Mario Zechner 8a0903ebf2 feat(ai): compat entrypoint, core-only root barrel (phase 5)
The root barrel is now core-only and side-effect free: types,
createModels/createProvider, auth substrate, lazyStream/lazyApi, faux,
utils. Generated catalogs, api-registry, env-api-keys, images, global
stream functions, and per-API lazy wrappers leave the root.

New @earendil-works/pi-ai/compat preserves the old surface verbatim as
a strict superset of the root: api-dispatch stream/complete with env
key injection, the builtin registration side effect (skip-if-present so
it cannot clobber earlier overrides), deprecated getModel/getModels/
getProviders aliases of the new getBuiltin* reads in providers/all,
lazy api wrappers + setBedrockProviderModule, and image generation.
Compat dies with the coding-agent ModelManager migration.

Packaging: exports map gains ./compat, ./providers/*, ./api/*;
sideEffects array lists only the effectful modules.

Old-global imports across agent/coding-agent/examples and pi-ai tests
switch to /compat (path-only; compat is a superset). The coding-agent
extension loader resolves the pi-ai ROOT specifier to compat, so
existing user extensions using the old global API keep working at
runtime until compat is removed. vitest configs alias /compat to src;
browser smoke imports old globals from /compat.
2026-06-10 21:17:12 +02:00

157 lines
4.6 KiB
TypeScript

/**
* Tests for AgentSession forking behavior.
*
* These tests verify:
* - Forking from a single message works
* - Forking in --no-session mode (in-memory only)
* - getUserMessagesForForking returns correct entries
*/
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getModel } from "@earendil-works/pi-ai/compat";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { AgentSession } from "../src/core/agent-session.ts";
import {
type AgentSessionRuntime,
type CreateAgentSessionRuntimeFactory,
createAgentSessionFromServices,
createAgentSessionRuntime,
createAgentSessionServices,
} from "../src/core/agent-session-runtime.ts";
import { AuthStorage } from "../src/core/auth-storage.ts";
import { SessionManager } from "../src/core/session-manager.ts";
import { API_KEY } from "./utilities.ts";
describe.skipIf(!API_KEY)("AgentSession forking", () => {
let session: AgentSession;
let runtimeHost: AgentSessionRuntime;
let tempDir: string;
let sessionManager: SessionManager;
beforeEach(() => {
tempDir = join(tmpdir(), `pi-branching-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
});
afterEach(async () => {
if (runtimeHost) {
await runtimeHost.dispose();
}
if (tempDir && existsSync(tempDir)) {
rmSync(tempDir, { recursive: true });
}
});
async function createSession(noSession: boolean = false) {
const model = getModel("anthropic", "claude-sonnet-4-5")!;
sessionManager = noSession ? SessionManager.inMemory(tempDir) : SessionManager.create(tempDir);
const authStorage = AuthStorage.create(join(tempDir, "auth.json"));
authStorage.setRuntimeApiKey("anthropic", API_KEY!);
const servicesOptions = {
agentDir: tempDir,
authStorage,
resourceLoaderOptions: {
noExtensions: true,
noSkills: true,
noPromptTemplates: true,
noThemes: true,
},
};
const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => {
const services = await createAgentSessionServices({
...servicesOptions,
cwd,
});
return {
...(await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent,
model,
tools: ["read", "bash", "edit", "write"],
})),
services,
diagnostics: services.diagnostics,
};
};
runtimeHost = await createAgentSessionRuntime(createRuntime, {
cwd: tempDir,
agentDir: tempDir,
sessionManager,
});
session = runtimeHost.session;
session.subscribe(() => {});
return session;
}
it("should allow forking from single message", async () => {
await createSession();
await session.prompt("Say hello");
await session.agent.waitForIdle();
const userMessages = session.getUserMessagesForForking();
expect(userMessages.length).toBe(1);
expect(userMessages[0].text).toBe("Say hello");
const result = await runtimeHost.fork(userMessages[0].entryId);
expect(result.cancelled).toBe(false);
session = runtimeHost.session;
expect(result.selectedText).toBe("Say hello");
expect(session.messages.length).toBe(0);
expect(session.sessionFile).not.toBeNull();
expect(existsSync(session.sessionFile!)).toBe(false);
});
it("should support in-memory forking in --no-session mode", async () => {
await createSession(true);
expect(session.sessionFile).toBeUndefined();
await session.prompt("Say hi");
await session.agent.waitForIdle();
const userMessages = session.getUserMessagesForForking();
expect(userMessages.length).toBe(1);
expect(session.messages.length).toBeGreaterThan(0);
const result = await runtimeHost.fork(userMessages[0].entryId);
expect(result.cancelled).toBe(false);
session = runtimeHost.session;
expect(result.selectedText).toBe("Say hi");
expect(session.messages.length).toBe(0);
expect(session.sessionFile).toBeUndefined();
});
it("should fork from middle of conversation", async () => {
await createSession();
await session.prompt("Say one");
await session.agent.waitForIdle();
await session.prompt("Say two");
await session.agent.waitForIdle();
await session.prompt("Say three");
await session.agent.waitForIdle();
const userMessages = session.getUserMessagesForForking();
expect(userMessages.length).toBe(3);
const secondMessage = userMessages[1];
const result = await runtimeHost.fork(secondMessage.entryId);
expect(result.cancelled).toBe(false);
session = runtimeHost.session;
expect(result.selectedText).toBe("Say two");
expect(session.messages.length).toBe(2);
expect(session.messages[0].role).toBe("user");
expect(session.messages[1].role).toBe("assistant");
}, 60000);
});