test(runtime): guard pi package contracts
This commit is contained in:
1 parent
c26e7420fb
commit
245586a9aa
6 files changed
+293
-26
No files matched your search
@@ -180,8 +180,8 @@ Field contracts:
|
||||
|
||||
- `meta.fixtureId` — stable kebab-case identifier used as the scenario name.
|
||||
- `meta.piAgentCoreVersion` — the `@mariozechner/pi-agent-core` version
|
||||
used when the fixture was recorded. Drift against the installed version
|
||||
is allowed but future tooling will surface a warning.
|
||||
used when the fixture was recorded. It must match the shipping version
|
||||
in `tests/support/pi-runtime-contract.ts`; drift fails `pnpm test:eval`.
|
||||
- `meta.systemPromptHash` — sha256 of the system prompt text. Changes in
|
||||
this hash are the canonical signal that a fixture is stale.
|
||||
- `turn.turnIndex` — strictly increasing from 0. Gaps or duplicates are
|
||||
@@ -231,6 +231,7 @@ provider tier described in `docs/proposals/B5.1-llm-as-judge-followup.md`.
|
||||
- `scenarios/<fixtureId>/system.txt` must hash to
|
||||
`meta.systemPromptHash`.
|
||||
- `scenarios/<fixtureId>/user.txt` must match the first user turn exactly.
|
||||
- `meta.piAgentCoreVersion` must match the shipping pi runtime version.
|
||||
- `assertions.semanticRubric` must contain at least one semantic dimension.
|
||||
- Structured assistant `toolCall` blocks require matching `toolResult` turns
|
||||
before the next assistant or user turn.
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
buildMatrixPrompt,
|
||||
loadScenarioContract,
|
||||
} from "./scenario-contract.js";
|
||||
import { PI_RUNTIME_VERSION } from "../support/pi-runtime-contract";
|
||||
import type {
|
||||
Assertion,
|
||||
AssistantTurn,
|
||||
@@ -307,6 +308,16 @@ describe("eval SOP coverage matrix", () => {
|
||||
|
||||
const fixturePaths = await listFixtures();
|
||||
|
||||
describe("eval pi runtime contract", () => {
|
||||
it("records every fixture against the shipping pi-agent-core version", async () => {
|
||||
expect(fixturePaths.length).toBeGreaterThan(0);
|
||||
for (const fixturePath of fixturePaths) {
|
||||
const fixture = await loadFixture(fixturePath);
|
||||
expect(fixture.meta.piAgentCoreVersion).toBe(PI_RUNTIME_VERSION);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(fixturePaths)("eval fixture %s", (fixturePath) => {
|
||||
it("passes all assertions", async () => {
|
||||
const fixture = await loadFixture(fixturePath);
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Agent } from "@mariozechner/pi-agent-core";
|
||||
import type { Model } from "@mariozechner/pi-ai";
|
||||
import { loadSkills } from "@mariozechner/pi-coding-agent";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAgentHooks } from "../../src/main/agent-runtime/agent-hooks";
|
||||
import {
|
||||
createPiAgent,
|
||||
resolveThinkingLevel,
|
||||
} from "../../src/main/worker/runtime/pi-agent-factory";
|
||||
import {
|
||||
createRunScopedBashOptions,
|
||||
createRunScopedCodingTools,
|
||||
} from "../../src/main/worker/runtime/run-tools";
|
||||
import type { WorkerRpcClient } from "../../src/main/worker/entry/rpc";
|
||||
|
||||
function createModel(): Model<"openai-responses"> {
|
||||
return {
|
||||
id: "contract-model",
|
||||
name: "Contract Model",
|
||||
api: "openai-responses",
|
||||
provider: "openai",
|
||||
baseUrl: "https://example.invalid",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 8192,
|
||||
maxTokens: 2048,
|
||||
};
|
||||
}
|
||||
|
||||
function createRpc(): WorkerRpcClient {
|
||||
return {
|
||||
invoke: async () => {
|
||||
throw new Error("Unexpected RPC call in pi runtime contract test.");
|
||||
},
|
||||
} as unknown as WorkerRpcClient;
|
||||
}
|
||||
|
||||
describe("pi runtime integration contract", () => {
|
||||
it("builds the AgentCompany pi Agent boundary with sequential execution and hooks", () => {
|
||||
const agent = createPiAgent({
|
||||
runId: "run-contract",
|
||||
taskSessionId: "session-contract",
|
||||
systemPrompt: "Contract prompt",
|
||||
model: createModel(),
|
||||
tools: [],
|
||||
priorMessages: [],
|
||||
thinkingLevel: resolveThinkingLevel("high"),
|
||||
apiKey: "contract-key",
|
||||
extraHeaders: { "x-contract": "true" },
|
||||
rpc: createRpc(),
|
||||
});
|
||||
|
||||
expect(agent).toBeInstanceOf(Agent);
|
||||
expect(agent.sessionId).toBe("session-contract");
|
||||
expect(agent.toolExecution).toBe("sequential");
|
||||
expect(agent.state.systemPrompt).toBe("Contract prompt");
|
||||
expect(agent.state.thinkingLevel).toBe("high");
|
||||
expect(agent.state.tools).toEqual([]);
|
||||
expect(agent.state.messages).toEqual([]);
|
||||
expect(typeof agent.beforeToolCall).toBe("function");
|
||||
expect(typeof agent.afterToolCall).toBe("function");
|
||||
expect(typeof agent.transformContext).toBe("function");
|
||||
});
|
||||
|
||||
it("keeps the coding-tool surface expected by worker runs", () => {
|
||||
const workspaceDir = mkdtempSync(join(tmpdir(), "agentcompany-pi-tools-"));
|
||||
try {
|
||||
const tools = createRunScopedCodingTools(workspaceDir, { AGENTCOMPANY_CONTRACT: "1" });
|
||||
expect(tools.map((tool) => tool.name)).toEqual(["read", "bash", "edit", "write"]);
|
||||
for (const tool of tools) {
|
||||
expect(typeof tool.execute).toBe("function");
|
||||
}
|
||||
} finally {
|
||||
rmSync(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves run-scoped environment through the bash spawn hook", () => {
|
||||
const options = createRunScopedBashOptions({
|
||||
AGENTCOMPANY_CONTRACT: "1",
|
||||
PATH: "/contract/bin",
|
||||
});
|
||||
const result = options.spawnHook?.({
|
||||
command: "pwd",
|
||||
cwd: "/tmp",
|
||||
env: {
|
||||
HOME: "/home/contract",
|
||||
PATH: "/base/bin",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result?.command).toBe("pwd");
|
||||
expect(result?.cwd).toBe("/tmp");
|
||||
expect(result?.env.HOME).toBe("/home/contract");
|
||||
expect(result?.env.PATH).toBe("/contract/bin");
|
||||
expect(result?.env.AGENTCOMPANY_CONTRACT).toBe("1");
|
||||
});
|
||||
|
||||
it("loads progressive-disclosure skills through the pinned pi runtime", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "agentcompany-pi-skills-"));
|
||||
const skillDir = join(root, "contract-skill");
|
||||
mkdirSync(skillDir);
|
||||
writeFileSync(
|
||||
join(skillDir, "SKILL.md"),
|
||||
[
|
||||
"---",
|
||||
"name: contract-skill",
|
||||
"description: Contract skill loaded by the pinned pi runtime.",
|
||||
"---",
|
||||
"# Contract Skill",
|
||||
"",
|
||||
"Use this skill to prove the runtime loader contract.",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
try {
|
||||
const result = loadSkills({ skillPaths: [skillDir], includeDefaults: false });
|
||||
expect(result.diagnostics).toEqual([]);
|
||||
expect(result.skills).toHaveLength(1);
|
||||
expect(result.skills[0]?.name).toBe("contract-skill");
|
||||
expect(result.skills[0]?.description).toBe("Contract skill loaded by the pinned pi runtime.");
|
||||
expect(result.skills[0]?.filePath).toBe(join(skillDir, "SKILL.md"));
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the aggregated hook bundle assignable to pi Agent options at runtime", () => {
|
||||
const hooks = buildAgentHooks({ runId: "run-contract", rpc: createRpc() });
|
||||
expect(Object.keys(hooks).sort()).toEqual([
|
||||
"afterToolCall",
|
||||
"beforeToolCall",
|
||||
"transformContext",
|
||||
]);
|
||||
expect(typeof hooks.beforeToolCall).toBe("function");
|
||||
expect(typeof hooks.afterToolCall).toBe("function");
|
||||
expect(typeof hooks.transformContext).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const PI_RUNTIME_VERSION = "0.67.68";
|
||||
|
||||
export const PI_RUNTIME_PACKAGES = [
|
||||
"@mariozechner/pi-agent-core",
|
||||
"@mariozechner/pi-ai",
|
||||
"@mariozechner/pi-coding-agent",
|
||||
] as const;
|
||||
|
||||
export type PiRuntimePackage = typeof PI_RUNTIME_PACKAGES[number];
|
||||
|
||||
export type PackageJson = {
|
||||
dependencies?: Record<string, string>;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
export function readJsonFile<T>(path: string): T {
|
||||
return JSON.parse(readFileSync(path, "utf8")) as T;
|
||||
}
|
||||
|
||||
export function readRootPackage(): PackageJson {
|
||||
return readJsonFile(join(process.cwd(), "package.json"));
|
||||
}
|
||||
|
||||
export function readPnpmLockfile(): string {
|
||||
return readFileSync(join(process.cwd(), "pnpm-lock.yaml"), "utf8");
|
||||
}
|
||||
|
||||
export function lockfileHasImporterPin(
|
||||
lockfile: string,
|
||||
packageName: PiRuntimePackage,
|
||||
version: string,
|
||||
): boolean {
|
||||
return lockfile.includes([
|
||||
` '${packageName}':`,
|
||||
` specifier: ${version}`,
|
||||
` version: ${version}(`,
|
||||
].join("\n"));
|
||||
}
|
||||
|
||||
export function lockfileHasPackageResolution(
|
||||
lockfile: string,
|
||||
packageName: PiRuntimePackage,
|
||||
version: string,
|
||||
): boolean {
|
||||
return lockfile.includes(` '${packageName}@${version}':\n resolution: {integrity: sha512-`);
|
||||
}
|
||||
|
||||
export function lockfileHasPackageSnapshot(
|
||||
lockfile: string,
|
||||
packageName: PiRuntimePackage,
|
||||
version: string,
|
||||
): boolean {
|
||||
return lockfile.includes(` '${packageName}@${version}(`);
|
||||
}
|
||||
|
||||
export function readInstalledPackage(packageName: PiRuntimePackage): PackageJson {
|
||||
const resolvedPath = fileURLToPath(import.meta.resolve(packageName));
|
||||
const marker = `/node_modules/${packageName}/`;
|
||||
const markerIndex = resolvedPath.lastIndexOf(marker);
|
||||
if (markerIndex < 0) {
|
||||
throw new Error(`Could not locate package root for ${packageName}: ${resolvedPath}`);
|
||||
}
|
||||
const packageRoot = resolvedPath.slice(0, markerIndex + marker.length - 1);
|
||||
return readJsonFile(join(packageRoot, "package.json"));
|
||||
}
|
||||
@@ -1,28 +1,51 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type PackageJson = {
|
||||
dependencies?: Record<string, string>;
|
||||
};
|
||||
|
||||
const PI_RUNTIME_PACKAGES = [
|
||||
"@mariozechner/pi-agent-core",
|
||||
"@mariozechner/pi-ai",
|
||||
"@mariozechner/pi-coding-agent",
|
||||
] as const;
|
||||
|
||||
function readRootPackage(): PackageJson {
|
||||
return JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf8")) as PackageJson;
|
||||
}
|
||||
import {
|
||||
PI_RUNTIME_PACKAGES,
|
||||
PI_RUNTIME_VERSION,
|
||||
lockfileHasImporterPin,
|
||||
lockfileHasPackageResolution,
|
||||
lockfileHasPackageSnapshot,
|
||||
readInstalledPackage,
|
||||
readPnpmLockfile,
|
||||
readRootPackage,
|
||||
} from "../support/pi-runtime-contract";
|
||||
|
||||
describe("pi runtime dependency contract", () => {
|
||||
it("keeps the shipping pi runtime on exact npm package pins", () => {
|
||||
const dependencies = readRootPackage().dependencies ?? {};
|
||||
|
||||
for (const packageName of PI_RUNTIME_PACKAGES) {
|
||||
expect(dependencies[packageName]).toBe("0.67.68");
|
||||
expect(dependencies[packageName]).toBe(PI_RUNTIME_VERSION);
|
||||
expect(dependencies[packageName]).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps package.json, pnpm-lock, and installed packages on one pi runtime version", () => {
|
||||
const lockfile = readPnpmLockfile();
|
||||
|
||||
for (const packageName of PI_RUNTIME_PACKAGES) {
|
||||
expect(lockfileHasImporterPin(lockfile, packageName, PI_RUNTIME_VERSION)).toBe(true);
|
||||
expect(lockfileHasPackageResolution(lockfile, packageName, PI_RUNTIME_VERSION)).toBe(true);
|
||||
expect(lockfileHasPackageSnapshot(lockfile, packageName, PI_RUNTIME_VERSION)).toBe(true);
|
||||
expect(readInstalledPackage(packageName).version).toBe(PI_RUNTIME_VERSION);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the pi API surface used by AgentCompany available", async () => {
|
||||
const [agentCore, piAi, piCodingAgent] = await Promise.all([
|
||||
import("@mariozechner/pi-agent-core"),
|
||||
import("@mariozechner/pi-ai"),
|
||||
import("@mariozechner/pi-coding-agent"),
|
||||
]);
|
||||
|
||||
expect(typeof agentCore.Agent).toBe("function");
|
||||
expect(typeof piAi.createAssistantMessageEventStream).toBe("function");
|
||||
expect(typeof piAi.getEnvApiKey).toBe("function");
|
||||
expect(typeof piAi.getModel).toBe("function");
|
||||
expect(typeof piAi.getModels).toBe("function");
|
||||
expect(typeof piAi.streamSimple).toBe("function");
|
||||
expect(typeof piAi.Type.Record).toBe("function");
|
||||
expect(typeof piCodingAgent.createCodingTools).toBe("function");
|
||||
expect(typeof piCodingAgent.loadSkills).toBe("function");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user