mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 18:55:22 +00:00
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>
265 lines
8.5 KiB
TypeScript
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" });
|
|
});
|
|
});
|