Files
my-pi/pi-permission-system/test/auto-review-transcript.test.ts
云服务部-叶林立andDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 7e6a8faae1 feat(pi-permission-system): AutoReview 改为跟随主模型的对话式工具调用审查
AutoReview:

- 审查器回到 Codex Guardian 风格的对话式审查,默认跟随会话当前主模型
  (每次审查实时读取 ctx.model);provider/model 必须同时设置才固定审查
  模型,只设其一视为配置错误并转人工。
- 结论只通过 approve / reject 两个工具给出,参数仅 reason;不强制
  toolChoice,正文文本(即使是合法 JSON)一律忽略。
- approve 自动放行;reject 转人工并在弹窗显示理由(标注模型生成)。
  超时、Provider 错误、截断、未调用工具、多次调用、参数无效等异常同样
  转人工,并附固定的系统说明;用户按 Esc 中止时直接拒绝。
- 模型调用改为 modelRegistry.streamSimple(...).result():Pi 1.3.0 面向
  扩展的 ModelRegistry 没有 completeSimple,原写法在真实环境中每次都会
  转人工。
- 对话记录保留可信度标注;工具结果在有更短的 pi-compaction 摘要时替换
  为摘要;token 估算改为非 ASCII 字符按 1 token 计;预算可配置。
- 同一条用户消息内完全相同的请求只缓存 approve 结论。
- 默认 reasoning low、总超时 30 秒、最多 2 次尝试、maxOutputTokens 4000。

删除:

- 未发布过的 TypeSafe Jev 分类器实现、DenialCircuitBreaker。
- 整个 annotator 机制(model-explanation、注册表、registerAnnotator、
  provider-timeout);annotators 配置键保留为已弃用并忽略,避免旧配置
  被拒绝。

测试:

- 新增 reviewer / transcript / 弹窗说明 / 默认配置测试;清理重复、恒真
  或只测实现细节的旧测试。
- 使用 deepseek-v4.1-flash 实测 5 个场景各 5 次,25 次结论全部符合预期。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-19 01:54:17 -07:00

265 lines
8.5 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
import {
collectTranscriptEntries,
estimateTokens,
renderTranscript,
truncateToApproximateTokens,
} from "#src/auto-review/transcript";
function entry(
id: string,
message: Record<string, unknown>,
): SessionEntry {
return { type: "message", id, message } as unknown as SessionEntry;
}
function user(id: string, text: string): SessionEntry {
return entry(id, { role: "user", content: text });
}
function toolResult(
id: string,
toolCallId: string,
text: string,
extras: Record<string, unknown> = {},
): SessionEntry {
return entry(id, {
role: "toolResult",
toolName: "read",
toolCallId,
isError: false,
content: [{ type: "text", text }],
...extras,
});
}
const renderedContent = (line: string) => JSON.parse(line).content as string;
const renderedLabel = (line: string) => JSON.parse(line).label as string;
describe("estimateTokens", () => {
it("counts ASCII at a quarter token per character", () => {
expect(estimateTokens("abcd")).toBe(1);
expect(estimateTokens("abcde")).toBe(2);
});
it("counts every non-ASCII code point as a full token", () => {
expect(estimateTokens("你好")).toBe(2);
expect(estimateTokens("ab你好cd")).toBe(1 + 2); // ceil(4/4) + 2
});
});
describe("truncateToApproximateTokens", () => {
it("bounds the estimated tokens of the rendered result", () => {
const text = "汉".repeat(5_000);
const truncated = truncateToApproximateTokens(text, 100);
expect(estimateTokens(truncated)).toBeLessThanOrEqual(100);
expect(truncated).toContain("...[truncated]...");
});
});
describe("renderTranscript summary substitution", () => {
it("replaces a tool result with a strictly shorter summary", () => {
const original = "x".repeat(500);
const branch = [
user("e1", "do it"),
toolResult("e2", "call-1", original),
];
const querySummaries = vi.fn(() => [
{ toolCallId: "call-1", deterministicSummary: "short summary" },
]);
const result = renderTranscript(branch, { querySummaries });
expect(querySummaries).toHaveBeenCalledTimes(1);
expect(querySummaries).toHaveBeenCalledWith(["call-1"]);
const toolLine = result.entries.find((line) =>
renderedLabel(line).includes("read"),
);
expect(toolLine).toBeDefined();
expect(renderedLabel(toolLine!)).toContain("(summarized)");
expect(renderedContent(toolLine!)).toBe("short summary\n[summarized]");
expect(result.stats.toolResultsSummarized).toBe(1);
});
it("prefers the semantic summary over the deterministic one", () => {
const branch = [user("e1", "go"), toolResult("e2", "call-1", "y".repeat(300))];
const result = renderTranscript(branch, {
querySummaries: () => [
{
toolCallId: "call-1",
semanticSummary: "semantic",
deterministicSummary: "deterministic",
},
],
});
const toolLine = result.entries.find((line) =>
renderedLabel(line).includes("summarized"),
);
expect(renderedContent(toolLine!)).toBe("semantic\n[summarized]");
});
it("keeps the original when the summary is not shorter", () => {
const branch = [user("e1", "go"), toolResult("e2", "call-1", "tiny")];
const result = renderTranscript(branch, {
querySummaries: () => [
{ toolCallId: "call-1", deterministicSummary: "a much longer summary that exceeds the original text" },
],
});
const toolLine = result.entries.find((line) =>
renderedLabel(line).includes("read"),
);
expect(renderedLabel(toolLine!)).not.toContain("summarized");
expect(renderedContent(toolLine!)).toBe("tiny");
expect(result.stats.toolResultsSummarized).toBe(0);
});
it("matches a record by resultTimestamp when both timestamps exist", () => {
const branch = [
user("e1", "go"),
toolResult("e2", "call-1", "z".repeat(300), { timestamp: 111 }),
];
const result = renderTranscript(branch, {
querySummaries: () => [
{ toolCallId: "call-1", resultTimestamp: 999, deterministicSummary: "wrong" },
{ toolCallId: "call-1", resultTimestamp: 111, deterministicSummary: "right" },
],
});
const toolLine = result.entries.find((line) =>
renderedLabel(line).includes("summarized"),
);
expect(renderedContent(toolLine!)).toBe("right\n[summarized]");
});
it("never summarizes a user_interaction result or an assistant toolCall argument", () => {
const branch = [
user("e1", "go"),
entry("e2", {
role: "assistant",
content: [
{
type: "toolCall",
id: "ask-1",
name: "ask_user_question",
arguments: { question: "proceed?" },
},
],
}),
entry("e3", {
role: "toolResult",
toolName: "ask_user_question",
toolCallId: "ask-1",
isError: false,
details: {
cancelled: false,
answers: [{ question: "proceed?", answer: "yes" }],
},
content: [{ type: "text", text: "answered" }],
}),
];
const querySummaries = vi.fn(() => [
{ toolCallId: "ask-1", deterministicSummary: "s" },
]);
const result = renderTranscript(branch, { querySummaries });
// The user_interaction entry carries no toolCallId, so the lookup is not
// even consulted for it; no (summarized) label may appear.
expect(result.entries.some((line) => renderedLabel(line).includes("summarized"))).toBe(false);
expect(result.stats.toolResultsSummarized).toBe(0);
});
it("queries once for all tool results together", () => {
const branch = [
user("e1", "go"),
toolResult("e2", "call-1", "a".repeat(300)),
toolResult("e3", "call-2", "b".repeat(300)),
];
const querySummaries = vi.fn(() => []);
renderTranscript(branch, { querySummaries });
expect(querySummaries).toHaveBeenCalledTimes(1);
expect(querySummaries).toHaveBeenCalledWith(["call-1", "call-2"]);
});
it("keeps originals when the lookup throws", () => {
const branch = [user("e1", "go"), toolResult("e2", "call-1", "c".repeat(300))];
const result = renderTranscript(branch, {
querySummaries: () => {
throw new Error("service down");
},
});
const toolLine = result.entries.find((line) =>
renderedLabel(line).includes("read"),
);
expect(renderedContent(toolLine!)).toBe("c".repeat(300));
expect(result.stats.toolResultsSummarized).toBe(0);
});
it("works without a lookup", () => {
const branch = [user("e1", "go"), toolResult("e2", "call-1", "d".repeat(300))];
const result = renderTranscript(branch);
const toolLine = result.entries.find((line) =>
renderedLabel(line).includes("read"),
);
expect(renderedContent(toolLine!)).toBe("d".repeat(300));
expect(result.stats.toolResultsSummarized).toBe(0);
});
});
describe("renderTranscript budgets", () => {
it("honours maxUntrustedEntries", () => {
const branch = [
user("e1", "go"),
toolResult("e2", "call-1", "one"),
toolResult("e3", "call-2", "two"),
toolResult("e4", "call-3", "three"),
];
const result = renderTranscript(branch, {
budgets: { messageTokens: 10_000, toolTokens: 10_000, maxUntrustedEntries: 2 },
});
const toolLines = result.entries.filter((line) =>
renderedLabel(line).includes("read"),
);
expect(toolLines).toHaveLength(2);
// Newest first: the two most recent results are retained.
expect(result.entries.some((line) => renderedContent(line) === "three")).toBe(true);
expect(result.entries.some((line) => renderedContent(line) === "one")).toBe(false);
});
it("always keeps the latest trusted user entry", () => {
const branch = [
user("e1", "first instruction " + "w".repeat(400)),
toolResult("e2", "call-1", "x".repeat(2_000)),
user("e3", "latest instruction"),
];
const result = renderTranscript(branch, {
budgets: { messageTokens: 50, toolTokens: 50, maxUntrustedEntries: 40 },
});
expect(result.entries.some((line) => renderedContent(line) === "latest instruction")).toBe(true);
expect(result.stats.latestTrustedEntryRetained).toBe(true);
expect(result.latestTrustedEntryId).toBe("e3");
});
});
describe("collectTranscriptEntries", () => {
it("emits user entries with their SessionEntry id", () => {
const entries = collectTranscriptEntries([user("e1", "hello")]);
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({ kind: "user", entryId: "e1" });
});
});