cf60e6df51
- Added a unified eval framework with parser grammar, backend interfaces, and JS/Python execution result types. - Added eval tool docs and updated prompts for fenced cells, `eval.py`/`eval.js`, and fallback behavior. - Replaced the built-in `python` tool with `eval` across registry, rendering, interactive modes, and tool settings. - Migrated Python execution runtime from `src/ipy` to `src/eval/py`, renamed state fields, and removed legacy introspection. - Refactored browser tooling from in-process VM helpers to worker-managed tab supervisors and protocol transport. - Added eval parser fallback and JS tool-bridge tests, updated imports, and removed obsolete python-mode suites.
337 lines
10 KiB
TypeScript
337 lines
10 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
|
import * as path from "node:path";
|
|
import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
|
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
|
|
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
|
import { TempDir } from "@oh-my-pi/pi-utils";
|
|
import { Type } from "@sinclair/typebox";
|
|
import { disposeAllVmContexts } from "../../src/eval/js/context-manager";
|
|
import { executeJs, type JsResult } from "../../src/eval/js/executor";
|
|
|
|
function createTool(
|
|
name: string,
|
|
execute: (toolCallId: string, args: unknown, signal?: AbortSignal) => Promise<AgentToolResult>,
|
|
): AgentTool {
|
|
return {
|
|
name,
|
|
label: name,
|
|
description: `${name} tool`,
|
|
parameters: Type.Object({}),
|
|
concurrency: "parallel",
|
|
execute,
|
|
} as unknown as AgentTool;
|
|
}
|
|
|
|
function getJsonData(result: JsResult): unknown {
|
|
const jsonOutputs = result.displayOutputs.filter(
|
|
(output): output is Extract<JsResult["displayOutputs"][number], { type: "json" }> => output.type === "json",
|
|
);
|
|
expect(jsonOutputs).toHaveLength(1);
|
|
return jsonOutputs[0].data;
|
|
}
|
|
|
|
function getStatusEvents(result: JsResult) {
|
|
return result.displayOutputs.filter(
|
|
(output): output is Extract<JsResult["displayOutputs"][number], { type: "status" }> => output.type === "status",
|
|
);
|
|
}
|
|
|
|
describe("executeJs", () => {
|
|
let tempDir: TempDir;
|
|
let session: ToolSession;
|
|
let sessionFile: string;
|
|
let sessionId: string;
|
|
|
|
beforeEach(async () => {
|
|
tempDir = TempDir.createSync("@js-executor-");
|
|
sessionFile = path.join(tempDir.path(), "session.jsonl");
|
|
sessionId = `session:${sessionFile}:cwd:${tempDir.path()}`;
|
|
session = {
|
|
cwd: tempDir.path(),
|
|
hasUI: false,
|
|
getSessionFile: () => sessionFile,
|
|
getSessionSpawns: () => null,
|
|
settings: Settings.isolated(),
|
|
};
|
|
|
|
await Bun.write(
|
|
path.join(tempDir.path(), "config.json"),
|
|
JSON.stringify({ name: "demo", enabled: true }, null, 2),
|
|
);
|
|
await Bun.write(path.join(tempDir.path(), "config.yaml"), "name: demo\nenabled: true\n");
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await disposeAllVmContexts();
|
|
tempDir.removeSync();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("persists bindings across calls and reset clears them", async () => {
|
|
await executeJs("const answer = 2;", { sessionId, session, sessionFile });
|
|
|
|
const persisted = await executeJs("return answer + 2;", { sessionId, session, sessionFile });
|
|
expect(persisted.exitCode).toBe(0);
|
|
expect(persisted.output.trim()).toBe("4");
|
|
|
|
const resetResult = await executeJs("return typeof answer;", {
|
|
sessionId,
|
|
session,
|
|
sessionFile,
|
|
reset: true,
|
|
});
|
|
expect(resetResult.exitCode).toBe(0);
|
|
expect(resetResult.output.trim()).toBe("undefined");
|
|
});
|
|
|
|
it("exposes a read-only safe process subset", async () => {
|
|
const result = await executeJs(
|
|
[
|
|
"return {",
|
|
" version: process.version,",
|
|
" versionsNode: typeof process.versions.node,",
|
|
" platform: process.platform,",
|
|
" arch: process.arch,",
|
|
" cwd: process.cwd(),",
|
|
" frozen: Object.isFrozen(process) && Object.isFrozen(process.versions),",
|
|
" hasEnv: 'env' in process,",
|
|
" hasExit: 'exit' in process,",
|
|
"};",
|
|
].join("\n"),
|
|
{ sessionId, session, sessionFile },
|
|
);
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
expect(getJsonData(result)).toEqual({
|
|
version: process.version,
|
|
versionsNode: "string",
|
|
platform: process.platform,
|
|
arch: process.arch,
|
|
cwd: tempDir.path(),
|
|
frozen: true,
|
|
hasEnv: false,
|
|
hasExit: false,
|
|
});
|
|
});
|
|
|
|
it("exposes common web globals and the raw Node fs module", async () => {
|
|
const result = await executeJs(
|
|
[
|
|
"const uuid = crypto.randomUUID();",
|
|
"const digest = await webcrypto.subtle.digest('SHA-256', new TextEncoder().encode('ok'));",
|
|
"const base = process.cwd();",
|
|
"fs.mkdirSync(`${base}/nested`, { recursive: true });",
|
|
"fs.writeFileSync(`${base}/nested/value.txt`, 'hello');",
|
|
"await fs.promises.copyFile(`${base}/nested/value.txt`, `${base}/nested/copy.txt`);",
|
|
"const text = fs.readFileSync(`${base}/nested/copy.txt`, 'utf8');",
|
|
"const bytes = await fs.promises.readFile(`${base}/nested/copy.txt`);",
|
|
"const stat = fs.statSync(`${base}/nested/copy.txt`);",
|
|
"const entries = fs.readdirSync(`${base}/nested`);",
|
|
"const start = performance.now();",
|
|
"return {",
|
|
" uuid: typeof uuid,",
|
|
" digestBytes: digest.byteLength,",
|
|
" text,",
|
|
" byteLength: bytes.byteLength,",
|
|
" isFile: stat.isFile(),",
|
|
" entries: entries.sort(),",
|
|
" hasConstants: typeof fs.constants.R_OK,",
|
|
" buffer: Buffer.from('ok').toString('hex'),",
|
|
" performance: typeof start,",
|
|
"};",
|
|
].join("\n"),
|
|
{ sessionId, session, sessionFile },
|
|
);
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
expect(getJsonData(result)).toEqual({
|
|
uuid: "string",
|
|
digestBytes: 32,
|
|
text: "hello",
|
|
byteLength: 5,
|
|
isFile: true,
|
|
entries: ["copy.txt", "value.txt"],
|
|
hasConstants: "number",
|
|
buffer: "6f6b",
|
|
performance: "number",
|
|
});
|
|
});
|
|
|
|
it("reads files as text and supports offset/limit slicing", async () => {
|
|
const result = await executeJs(
|
|
[
|
|
"const full = await read('config.json');",
|
|
"const sliced = await read('config.json', { offset: 2, limit: 1 });",
|
|
"return { isString: typeof full === 'string', full, sliced };",
|
|
].join("\n"),
|
|
{
|
|
sessionId,
|
|
session,
|
|
sessionFile,
|
|
},
|
|
);
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
expect(getStatusEvents(result)).toHaveLength(2);
|
|
expect(getJsonData(result)).toEqual({
|
|
isString: true,
|
|
full: '{\n "name": "demo",\n "enabled": true\n}',
|
|
sliced: ' "name": "demo",',
|
|
});
|
|
});
|
|
|
|
it("rejects protocol paths and directory reads from native read()", async () => {
|
|
const protocolResult = await executeJs("await read('agent://demo');", {
|
|
sessionId,
|
|
session,
|
|
sessionFile,
|
|
});
|
|
expect(protocolResult.exitCode).toBe(1);
|
|
expect(protocolResult.output).toContain("Protocol paths are not supported");
|
|
|
|
const directoryResult = await executeJs("await read('.');", {
|
|
sessionId,
|
|
session,
|
|
sessionFile,
|
|
});
|
|
expect(directoryResult.exitCode).toBe(1);
|
|
expect(directoryResult.output).toContain("Directory paths are not supported");
|
|
});
|
|
|
|
it("supports parse/stringify helpers and row utilities", async () => {
|
|
const result = await executeJs(
|
|
[
|
|
'const csv = \'name,role,note\\nalice,admin,"hello, world"\\nbob,user,"line1\\nline2"\';',
|
|
"const parsed = await parse.csv(csv);",
|
|
"const tsv = stringify.tsv(rows.pick(parsed, ['name', 'role']));",
|
|
"const roundtrip = await parse.tsv(tsv);",
|
|
"const grouped = rows.groupBy(parsed, 'role');",
|
|
"const counted = rows.countBy(parsed, 'role');",
|
|
"const sorted = rows.sortBy(parsed, 'name', { reverse: true });",
|
|
"return { parsed, roundtrip, groupedSizes: Object.fromEntries(Object.entries(grouped).map(([key, value]) => [key, value.length])), counted, sorted: rows.column(sorted, 'name') };",
|
|
].join("\n"),
|
|
{
|
|
sessionId,
|
|
session,
|
|
sessionFile,
|
|
},
|
|
);
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.displayOutputs).toEqual([
|
|
{
|
|
type: "json",
|
|
data: {
|
|
parsed: [
|
|
{ name: "alice", role: "admin", note: "hello, world" },
|
|
{ name: "bob", role: "user", note: "line1\nline2" },
|
|
],
|
|
roundtrip: [
|
|
{ name: "alice", role: "admin" },
|
|
{ name: "bob", role: "user" },
|
|
],
|
|
groupedSizes: { admin: 1, user: 1 },
|
|
counted: { admin: 1, user: 1 },
|
|
sorted: ["bob", "alice"],
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("round-trips text and binary writes", async () => {
|
|
const result = await executeJs(
|
|
[
|
|
"await write('plain.txt', 'hello');",
|
|
"await write('bin.dat', new Uint8Array([0, 1, 2, 255]));",
|
|
"await write.json('state.json', { ok: true });",
|
|
"await write.csv('table.csv', [{ name: 'alice', role: 'admin' }]);",
|
|
"return {",
|
|
" text: await read.text('plain.txt'),",
|
|
" bytes: Array.from(await read.bytes('bin.dat')),",
|
|
" json: await read.json('state.json'),",
|
|
" csv: await read.csv('table.csv'),",
|
|
"};",
|
|
].join("\n"),
|
|
{
|
|
sessionId,
|
|
session,
|
|
sessionFile,
|
|
},
|
|
);
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
expect(getStatusEvents(result)).toHaveLength(8);
|
|
expect(getJsonData(result)).toEqual({
|
|
text: "hello",
|
|
bytes: [0, 1, 2, 255],
|
|
json: { ok: true },
|
|
csv: [{ name: "alice", role: "admin" }],
|
|
});
|
|
});
|
|
|
|
it("routes output() through tool.read and keeps tool.* results normalized", async () => {
|
|
const execute = vi.fn(async (_toolCallId: string, args: unknown): Promise<AgentToolResult> => {
|
|
const record = args as { path: string };
|
|
if (record.path.startsWith("agent://")) {
|
|
return { content: [{ type: "text", text: "from-agent" }] };
|
|
}
|
|
return {
|
|
content: [{ type: "text", text: "annotated" }],
|
|
details: { path: record.path, kind: "tool-result" },
|
|
};
|
|
});
|
|
const toolSession: ToolSession = {
|
|
...session,
|
|
getToolByName: name => (name === "read" ? createTool("read", execute) : undefined),
|
|
};
|
|
|
|
const result = await executeJs(
|
|
"return { toolResult: await tool.read({ path: 'package.json' }), agentOutput: await output('agent-42') };",
|
|
{
|
|
sessionId,
|
|
session: toolSession,
|
|
sessionFile,
|
|
},
|
|
);
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
expect(getStatusEvents(result)).toHaveLength(2);
|
|
expect(getJsonData(result)).toEqual({
|
|
toolResult: {
|
|
text: "annotated",
|
|
details: { path: "package.json", kind: "tool-result" },
|
|
images: undefined,
|
|
},
|
|
agentOutput: "from-agent",
|
|
});
|
|
expect(execute).toHaveBeenCalledTimes(2);
|
|
expect(execute.mock.calls[0]?.[1]).toEqual({ path: "package.json", _i: "js prelude" });
|
|
expect(execute.mock.calls[1]?.[1]).toEqual({ path: "agent://agent-42", _i: "js prelude" });
|
|
});
|
|
|
|
it("auto-displays returned objects as structured output", async () => {
|
|
const result = await executeJs("return { answer: 42, nested: { ok: true } };", {
|
|
sessionId,
|
|
session,
|
|
sessionFile,
|
|
});
|
|
|
|
expect(result.exitCode).toBe(0);
|
|
expect(result.output).toBe("");
|
|
expect(result.displayOutputs).toEqual([{ type: "json", data: { answer: 42, nested: { ok: true } } }]);
|
|
});
|
|
|
|
it("cancels execution when the timeout expires", async () => {
|
|
const result = await executeJs("await new Promise(() => {})", {
|
|
sessionId,
|
|
session,
|
|
sessionFile,
|
|
timeoutMs: 20,
|
|
});
|
|
|
|
expect(result.cancelled).toBe(true);
|
|
expect(result.exitCode).toBeUndefined();
|
|
expect(result.output).toContain("Command timed out");
|
|
});
|
|
});
|