merge: pi-durable(智能体运行时迁到 pi-durable 1.1 + pi-ai 1.1)
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei # Conflicts: # CHANGELOG.md
This commit is contained in:
commit
67f6fb48ac
26 files changed
+877
-366
No files matched your search
@@ -106,6 +106,7 @@ export function makeFixture(opts: { slowMs?: number } = {}): Fixture {
|
||||
|
||||
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (signal?.aborted) return reject(new Error("aborted by signal"));
|
||||
const t = setTimeout(resolve, ms);
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { AgentRunEvent } from "@umt/contracts";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
type AgentRunHandle,
|
||||
type AgentSettings,
|
||||
checkToolCall,
|
||||
createFakeRuntime,
|
||||
type FakeScript,
|
||||
LegacySessionError,
|
||||
type RuntimeReport,
|
||||
} from "../src/index.ts";
|
||||
import { crashScript, RUN_ID } from "./fixtures/crash-script.ts";
|
||||
import { type Fixture, makeFixture, settings, tempDir, types } from "./helpers.ts";
|
||||
|
||||
const opened: AgentRunHandle[] = [];
|
||||
|
||||
/** 会话存储目录下全部文件的文本(pi-durable JSONL 主文件 + 附属文件) */
|
||||
function sessionText(dir: string): string {
|
||||
return readdirSync(dir, { recursive: true, withFileTypes: true })
|
||||
.filter((f) => f.isFile())
|
||||
.map((f) => readFileSync(join(f.parentPath, f.name), "utf8"))
|
||||
.join("\n");
|
||||
}
|
||||
afterEach(async () => {
|
||||
await Promise.all(opened.splice(0).map((r) => r.close()));
|
||||
});
|
||||
@@ -72,7 +83,7 @@ async function start(
|
||||
}
|
||||
|
||||
describe("PiAgentRuntime + FakeModelProvider", () => {
|
||||
it("完整闭环:事件顺序、usage、最终文本、JSONL 位于 sessionDir/<runId>.jsonl", async () => {
|
||||
it("完整闭环:事件顺序、usage、最终文本、会话存储位于 sessionDir/<runId>/", async () => {
|
||||
const fx = makeFixture();
|
||||
fx.grants.set("user:1", new Set(["download.submit"]));
|
||||
const { run, sessionDir } = await start(fx, [dune]);
|
||||
@@ -104,9 +115,11 @@ describe("PiAgentRuntime + FakeModelProvider", () => {
|
||||
const last = events.at(-1)!;
|
||||
expect(last).toMatchObject({ type: "run_completed", finalText: outcome.finalText });
|
||||
|
||||
expect(run.sessionPath).toBe(join(sessionDir, "run-1.jsonl"));
|
||||
expect(run.sessionPath).toBe(join(sessionDir, "run-1"));
|
||||
expect(existsSync(run.sessionPath)).toBe(true);
|
||||
expect(readFileSync(run.sessionPath, "utf8")).toContain("download__submit");
|
||||
expect(sessionText(run.sessionPath)).toContain("download__submit");
|
||||
const end = events.find((e) => e.type === "tool_call_end");
|
||||
expect(end).toMatchObject({ durationMs: expect.any(Number) });
|
||||
// 审计来自 agent,带 invocationId 与 runId
|
||||
expect(fx.audits.find((a) => a.capabilityId === "download.submit")).toMatchObject({
|
||||
source: "agent",
|
||||
@@ -233,7 +246,7 @@ describe("PiAgentRuntime + FakeModelProvider", () => {
|
||||
for (const secret of ["abcdef123456", "deadbeef", "sk-live-XXXX"]) {
|
||||
expect(transcript).not.toContain(secret);
|
||||
expect(JSON.stringify(run.history())).not.toContain(secret);
|
||||
expect(readFileSync(run.sessionPath, "utf8")).not.toContain(secret);
|
||||
expect(sessionText(run.sessionPath)).not.toContain(secret);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -382,4 +395,111 @@ describe("PiAgentRuntime + FakeModelProvider", () => {
|
||||
expect(recovered).toMatchObject({ capabilityId: "download.submit", isError: true, recovery: true });
|
||||
expect(run.history()[0]).toMatchObject({ type: "run_started", resumed: true });
|
||||
}, 30_000);
|
||||
|
||||
it("挂起:工具执行中关闭会话(结果不落盘),换进程重开后 resume 重放该调用并完成", async () => {
|
||||
const sessionDir = await tempDir();
|
||||
const fx = makeFixture();
|
||||
let handle: AgentRunHandle | undefined;
|
||||
const calls: Array<{ inv: string; resumed: boolean }> = [];
|
||||
let suspended = true;
|
||||
fx.registry.register(
|
||||
{
|
||||
id: "agent.await",
|
||||
version: 1,
|
||||
title: "等待子会话",
|
||||
description: "等子会话落定。",
|
||||
inputSchema: z.object({ runIds: z.array(z.string()) }),
|
||||
outputSchema: z.object({ allDone: z.boolean() }),
|
||||
kind: "read",
|
||||
scope: "read",
|
||||
riskLevel: "low",
|
||||
idempotent: true,
|
||||
supportsPreview: false,
|
||||
replay: "safe",
|
||||
},
|
||||
async (_input, c) => {
|
||||
calls.push({ inv: c.invocationId ?? "", resumed: !suspended });
|
||||
if (suspended) {
|
||||
// 与 server 的 suspendRun 一样:在工具内部关闭会话,返回值不会进入模型上下文
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await handle!.close();
|
||||
return { allDone: false };
|
||||
}
|
||||
return { allDone: true };
|
||||
},
|
||||
);
|
||||
const script: FakeScript = {
|
||||
match: "Dune",
|
||||
steps: [{ toolCalls: [{ capability: "agent.await", input: { runIds: ["child-1"] } }] }],
|
||||
finalText: (r) => `子会话:${r.at(-1)?.text}`,
|
||||
};
|
||||
const first = await start(fx, [script], { sessionDir, runId: "run-sus", capabilities: ["agent.await"] });
|
||||
handle = first.run;
|
||||
await expect(first.run.prompt()).rejects.toThrow();
|
||||
await first.run.close();
|
||||
expect(first.run.history().some((e) => e.type === "tool_call_end")).toBe(false);
|
||||
|
||||
suspended = false;
|
||||
const second = await start(fx, [script], {
|
||||
sessionDir,
|
||||
runId: "run-sus",
|
||||
capabilities: ["agent.await"],
|
||||
firstSeq: first.run.history().length,
|
||||
});
|
||||
expect(second.run.pendingOperations).toBe(1);
|
||||
const out = await second.run.resume();
|
||||
expect(out).toMatchObject({ status: "completed", finalText: '子会话:{"allDone":true}' });
|
||||
expect(calls).toHaveLength(2);
|
||||
expect(calls[1]).toMatchObject({ inv: calls[0]!.inv, resumed: true });
|
||||
const ends = second.run.history().filter((e) => e.type === "tool_call_end");
|
||||
expect(ends).toEqual([expect.objectContaining({ capabilityId: "agent.await", isError: false, recovery: true })]);
|
||||
});
|
||||
|
||||
it("旧版(pi-agent-core 0.99)会话文件:打开时给出明确错误,不崩溃", async () => {
|
||||
const sessionDir = await tempDir();
|
||||
writeFileSync(join(sessionDir, "run-old.jsonl"), '{"type":"header","id":"run-old","storageVersion":4}\n');
|
||||
const fx = makeFixture();
|
||||
const err = await start(fx, [dune], { sessionDir, runId: "run-old" }).catch((e: unknown) => e);
|
||||
expect(err).toBeInstanceOf(LegacySessionError);
|
||||
expect((err as Error).message).toContain("旧版本");
|
||||
});
|
||||
|
||||
it("扩展钩子抛错:上报 onReport(带 runId 与位置、已脱敏),pi-durable 照常按 block 处理", async () => {
|
||||
const fx = makeFixture();
|
||||
const reports: RuntimeReport[] = [];
|
||||
const { runtime } = createFakeRuntime({
|
||||
registry: fx.registry,
|
||||
scripts: [
|
||||
{
|
||||
match: "Dune",
|
||||
steps: [{ toolCalls: [{ capability: "inventory.query", input: { title: "Dune" } }] }],
|
||||
finalText: (r) => `结果:${r.at(-1)?.isError}`,
|
||||
},
|
||||
],
|
||||
onReport: (r) => reports.push(r),
|
||||
});
|
||||
const run = await runtime.createRun({
|
||||
runId: "run-report",
|
||||
goal: "Dune",
|
||||
systemPrompt: "x",
|
||||
capabilities: ["inventory.query"],
|
||||
settings: settings(),
|
||||
sessionDir: await tempDir(),
|
||||
actor: { actorId: "user:1", taskId: "t", scopeIds: [] },
|
||||
externalUsage: () => {
|
||||
throw new Error("子会话用量读取失败 passkey=abcdef123456");
|
||||
},
|
||||
});
|
||||
opened.push(run);
|
||||
const out = await run.prompt();
|
||||
expect(out).toMatchObject({ status: "completed", finalText: "结果:true" });
|
||||
expect(reports).toEqual([
|
||||
expect.objectContaining({
|
||||
runId: "run-report",
|
||||
source: "hook:beforeTool",
|
||||
message: expect.stringContaining("子会话用量读取失败"),
|
||||
}),
|
||||
]);
|
||||
expect(reports[0]?.message).not.toContain("abcdef123456");
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,12 @@ describe("AgentSettings → pi-ai", () => {
|
||||
});
|
||||
const { model } = resolveModelFromSettings(s);
|
||||
expect(model).toMatchObject({ api: "openai-completions", baseUrl: "http://llm.local/v1", reasoning: false });
|
||||
expect(compactionFromSettings(s, 32_000)).toEqual({ enabled: true, reserveTokens: 8000, keepRecentTokens: 8000 });
|
||||
expect(compactionFromSettings(s, 32_000)).toEqual({
|
||||
enabled: true,
|
||||
reserveTokens: 8000,
|
||||
keepRecentTokens: 8000,
|
||||
backgroundTokens: 0,
|
||||
});
|
||||
const manual = resolveAgentSettings({ ...s, compaction: { mode: "manual", threshold: 0.8 } });
|
||||
expect(compactionFromSettings(manual, 32_000).enabled).toBe(false);
|
||||
expect(() => resolveAgentSettings({ ...base, model: "" })).toThrow();
|
||||
|
||||
@@ -23,7 +23,7 @@ beforeAll(async () => {
|
||||
|
||||
const REQUIRED_SECTIONS = ["## 适用意图", "## 步骤", "## 需要批准", "## 汇报模板", "## 不要做"];
|
||||
|
||||
describe("加载(Pi harness loadSkills)", () => {
|
||||
describe("加载(skills/<name>/SKILL.md)", () => {
|
||||
it("至少 5 个 Skill,没有格式诊断", () => {
|
||||
expect(diagnostics).toEqual([]);
|
||||
expect(skills.length).toBeGreaterThanOrEqual(5);
|
||||
|
||||
Reference in new issue
Block a user