Initialize repository with harness code and assets

Initial import of all source code, config, and README assets: the
packages workspace (cli, core, server, web, docs, landing, skills),
build scripts, tooling config, and CI workflows.

Includes the data-layout revision made on this branch: the local data
root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the
installer keeps its binaries in ~/.penguin), and every Agent lives
under <project>/agents/<agent>/ — path helpers, the three
agent-enumeration scans, the system prompt, built-in Skills, tests
and docs all follow the new layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
This commit is contained in:
Yaowei Zheng
2026-07-19 14:06:53 +08:00
committed by GitHub
parent 056bed7aeb
commit 45bfae6e94
543 changed files with 92949 additions and 0 deletions
+209
View File
@@ -0,0 +1,209 @@
/**
* agent-mentions.ts unit tests: @ mention matching (cursor prefix / boundary
* rules), candidate filtering, send-time parsing of a leading @ mention,
* generation of the first new-conversation <handoff_from> origin block, and
* <scheduled_task> origin block parsing.
*/
import { describe, expect, it } from "vitest";
import type { AgentSummary } from "@prismshadow/penguin-server/api";
import {
filterAgents,
handoffMessage,
matchMention,
parseHandoffMessage,
parseScheduledMessage,
splitLeadingMention,
} from "../src/features/chat/agent-mentions";
const agent = (agentId: string, name?: string): AgentSummary => ({
agentId,
...(name !== undefined ? { name } : {}),
activeSessionCount: 0,
sessionCount: 0,
sessionActivity: [],
toolCount: 0,
version: 1,
vaultKeyCount: 0,
scheduleCount: 0,
});
const AGENTS: AgentSummary[] = [
agent("default_agent", "General Agent"),
agent("agent_creator", "Agent Creator"),
agent("agent_optimizer", "Agent Optimizer"),
agent("researcher"),
];
describe("matchMention(光标处正在输入的 @ 前缀)", () => {
it("文首与空白后的 @ 触发;query 为 @ 到光标之间的前缀,光标在 token 末尾时 end == 光标", () => {
expect(matchMention("@", 1)).toEqual({ start: 0, end: 1, query: "" });
expect(matchMention("@age", 4)).toEqual({ start: 0, end: 4, query: "age" });
expect(matchMention("fix it @res", 11)).toEqual({ start: 7, end: 11, query: "res" });
expect(matchMention("line1\n@a", 8)).toEqual({ start: 6, end: 8, query: "a" });
});
it("非空白后的 @(如邮箱)不触发", () => {
expect(matchMention("mail me a@b", 11)).toBeNull();
expect(matchMention("a@", 2)).toBeNull();
});
it("@ 与光标之间含非 id 字符(空格等)不触发", () => {
expect(matchMention("@agent done", 11)).toBeNull();
expect(matchMention("no at here", 10)).toBeNull();
});
it("光标在 token 中间也生效:end 覆盖光标右侧的整个残段(替换不残留后半段)", () => {
expect(matchMention("@agent_creator", 3)).toEqual({ start: 0, end: 14, query: "ag" });
// The remainder stops at the token boundary (whitespace/end of text): trailing plain text is not swallowed.
expect(matchMention("@ag rest", 3)).toEqual({ start: 0, end: 3, query: "ag" });
expect(matchMention("see @agent_creator now", 7)).toEqual({ start: 4, end: 18, query: "ag" });
});
});
describe("filterAgents(前缀过滤)", () => {
it("空前缀返回全部候选", () => {
expect(filterAgents(AGENTS, "")).toHaveLength(4);
});
it("按 agentId 前缀过滤,大小写不敏感", () => {
expect(filterAgents(AGENTS, "agent_").map((a) => a.agentId)).toEqual([
"agent_creator",
"agent_optimizer",
]);
expect(filterAgents(AGENTS, "RES").map((a) => a.agentId)).toEqual(["researcher"]);
});
it("显示名前缀同样命中", () => {
expect(filterAgents(AGENTS, "General").map((a) => a.agentId)).toEqual(["default_agent"]);
});
});
describe("splitLeadingMention(句首 @ 的发送时解析)", () => {
it("以现有 agentId 开头:拆出目标与剩余正文(开头空白修剪,换行同样吸收)", () => {
expect(splitLeadingMention("@researcher check this", AGENTS)).toEqual({
agent: AGENTS[3],
rest: "check this",
});
expect(splitLeadingMention("@researcher\nnext line", AGENTS)).toEqual({
agent: AGENTS[3],
rest: "next line",
});
});
it("只 @ 无正文:rest 为空串;标点紧随 token 时保留在正文里", () => {
expect(splitLeadingMention("@agent_creator", AGENTS)).toEqual({
agent: AGENTS[1],
rest: "",
});
expect(splitLeadingMention("@researcher, please", AGENTS)).toEqual({
agent: AGENTS[3],
rest: ", please",
});
});
it("id 取最长 [\\w-]+ 串精确匹配:超长/不存在的 token 不算(@foo2 不算 @ 到 foo)", () => {
expect(splitLeadingMention("@agent_creator2 x", AGENTS)).toBeNull();
expect(splitLeadingMention("@nobody x", AGENTS)).toBeNull();
});
it("非句首的 @ 一律不解析(正文中间的 @ 是普通文字)", () => {
expect(splitLeadingMention("hi @researcher", AGENTS)).toBeNull();
expect(splitLeadingMention("plain text", AGENTS)).toBeNull();
});
});
describe("handoffMessage(新对话首条 <handoff_from> 来源块)", () => {
it("完整来源:agent 显示名与 Session 标题括注、Workspace 各占一行", () => {
const text = handoffMessage({
agentId: "default_agent",
agentName: "General Agent",
sessionId: "session-01ABC",
sessionTitle: "Fix the parser",
workspace: "/data/ws",
});
expect(text.startsWith("<handoff_from>\n")).toBe(true);
expect(text.endsWith("\n</handoff_from>")).toBe(true);
expect(text).toContain("agent: default_agent (General Agent)");
expect(text).toContain("session: session-01ABC (Fix the parser)");
expect(text).toContain("workspace: /data/ws");
});
it("草稿态(无 Session)只带来源 agent;显示名与 id 相同或缺省时省略括注", () => {
const text = handoffMessage({ agentId: "researcher", agentName: "researcher" });
expect(text).toContain("agent: researcher\n");
expect(text).not.toContain("session:");
expect(text).not.toContain("workspace:");
expect(handoffMessage({ agentId: "researcher" })).toContain("agent: researcher\n");
});
it("Session 标题缺省时省略括注", () => {
const text = handoffMessage({
agentId: "default_agent",
sessionId: "session-01ABC",
workspace: "/data/ws",
});
expect(text).toContain("session: session-01ABC\n");
});
});
describe("parseHandoffMessage(来源块逆向解析,驱动交接提示渲染)", () => {
it("与 handoffMessage 往返一致(含带括号的 Session 标题)", () => {
const origin = {
agentId: "default_agent",
agentName: "General Agent",
sessionId: "session-01ABC",
sessionTitle: "Fix (the) parser",
workspace: "/data/ws",
};
expect(parseHandoffMessage(handoffMessage(origin))).toEqual(origin);
});
it("最小来源(仅 agent)往返;显示名与 id 相同时生成端省略括注、解析端只还原 id", () => {
expect(parseHandoffMessage(handoffMessage({ agentId: "researcher" }))).toEqual({
agentId: "researcher",
});
expect(
parseHandoffMessage(handoffMessage({ agentId: "researcher", agentName: "researcher" })),
).toEqual({ agentId: "researcher" });
});
it("普通消息与只是包含来源块的更长消息不误判", () => {
expect(parseHandoffMessage("hello @default_agent")).toBeNull();
expect(parseHandoffMessage(`before\n${handoffMessage({ agentId: "a1" })}`)).toBeNull();
expect(parseHandoffMessage(`${handoffMessage({ agentId: "a1" })}\nafter`)).toBeNull();
});
});
describe("parseScheduledMessage(定时任务来源块解析,驱动定时提示渲染)", () => {
/** Shape of the server-side scheduledMessage output (scheduler.ts). */
const scheduled = (name: string, firedAt: string, prompt: string): string =>
[
"<scheduled_task>",
"This message was sent automatically by a scheduled task; its origin is listed below and the task prompt follows.",
`schedule: ${name}`,
`fired_at: ${firedAt}`,
"</scheduled_task>",
"",
prompt,
].join("\n");
it("解析来源块并返回剩余正文(正文照常渲染)", () => {
const text = scheduled("daily_report", "2026-07-16T01:00:00.000Z", "写日报\n附昨日数据");
expect(parseScheduledMessage(text)).toEqual({
origin: { name: "daily_report", firedAt: "2026-07-16T01:00:00.000Z" },
rest: "写日报\n附昨日数据",
});
});
it("普通消息与中途出现的块不误判;缺任务名视为非来源块", () => {
expect(parseScheduledMessage("hello")).toBeNull();
expect(
parseScheduledMessage(`前言\n${scheduled("t", "2026-01-01T00:00:00Z", "p")}`),
).toBeNull();
expect(
parseScheduledMessage(
"<scheduled_task>\nfired_at: 2026-01-01T00:00:00Z\n</scheduled_task>\n\np",
),
).toBeNull();
});
});
+42
View File
@@ -0,0 +1,42 @@
/**
* splitImageAttachments unit tests: splitting "[attached image: …]" attachment
* lines out of user text — scratchpad paths map to the session file endpoint,
* http(s) URLs are quoted as-is, and unrecognized lines stay in the text.
*/
import { describe, expect, it } from "vitest";
import { splitImageAttachments } from "../src/lib/attachments";
describe("splitImageAttachments", () => {
it("scratchpad 路径行 → 会话文件端点,正文保留", () => {
const { text, images } = splitImageAttachments(
"看看这张图\n\n[attached image: /home/u/.penguin/data/p1/agents/a1/scratchpad/session-20260712-abc/upload-1752300000000-0.png]",
);
expect(text).toBe("看看这张图");
expect(images).toEqual([
"/api/sessions/session-20260712-abc/scratchpad/upload-1752300000000-0.png",
]);
});
it("http(s) URL 直引;多附件按顺序", () => {
const { text, images } = splitImageAttachments(
"[attached image: https://example.com/a.png]\n[attached image: /x/scratchpad/s1/b.png]",
);
expect(text).toBe("");
expect(images).toEqual(["https://example.com/a.png", "/api/sessions/s1/scratchpad/b.png"]);
});
it("识别不出的行保留在文本(说明行 / 非本系统路径)", () => {
const { text, images } = splitImageAttachments(
"hi\n\n[an attached image could not be saved and was dropped]\n[attached image: /etc/passwd]",
);
expect(images).toEqual([]);
expect(text).toContain("could not be saved");
expect(text).toContain("[attached image: /etc/passwd]");
});
it("无附件行原样返回", () => {
const { text, images } = splitImageAttachments("普通消息");
expect(text).toBe("普通消息");
expect(images).toEqual([]);
});
});
+121
View File
@@ -0,0 +1,121 @@
/**
* Unit tests for benchmark-metrics.ts: metric switching on the evaluation-center
* chart (score / cost / duration) — value extraction (missing -> null), gap
* segmentation (skipped points: connect within a segment, break between
* segments, a lone point still forms its own segment), and the y-axis max.
*/
import { describe, expect, it } from "vitest";
import {
lineSegments,
metricMax,
metricValues,
modelSeries,
seriesValues,
} from "../src/features/benchmark/benchmark-metrics";
const evaluations = [
{ score: 6, cost: 0.12, durationMs: 90_000 },
{ score: 7.5 }, // legacy record: no cost / durationMs
{ score: 8.5, cost: 0.2, durationMs: 60_000 },
];
describe("metricValues(按指标取值,缺失为 null)", () => {
it("score 恒有", () => {
expect(metricValues(evaluations, "score")).toEqual([6, 7.5, 8.5]);
});
it("cost / duration 缺失给 null(跳点)", () => {
expect(metricValues(evaluations, "cost")).toEqual([0.12, null, 0.2]);
expect(metricValues(evaluations, "duration")).toEqual([90_000, null, 60_000]);
});
it("非有限值(NaN / Infinity)按缺失对待", () => {
expect(metricValues([{ score: 1, cost: Number.NaN }], "cost")).toEqual([null]);
expect(metricValues([{ score: 1, durationMs: Infinity }], "duration")).toEqual([null]);
});
});
describe("lineSegments(缺口切段)", () => {
it("无缺口:单段全量(下标连续)", () => {
expect(lineSegments([6, 7.5, 8.5])).toEqual([
[
{ index: 0, value: 6 },
{ index: 1, value: 7.5 },
{ index: 2, value: 8.5 },
],
]);
});
it("中间缺口断开为两段(单点段照样成段:只画点不画线)", () => {
expect(lineSegments([0.12, null, 0.2])).toEqual([
[{ index: 0, value: 0.12 }],
[{ index: 2, value: 0.2 }],
]);
expect(lineSegments([null, 1, 2, null, 3])).toEqual([
[
{ index: 1, value: 1 },
{ index: 2, value: 2 },
],
[{ index: 4, value: 3 }],
]);
});
it("全缺失 / 空列表:无段", () => {
expect(lineSegments([null, null])).toEqual([]);
expect(lineSegments([])).toEqual([]);
});
});
describe("metricMax(纵轴上界)", () => {
it("取有值点的最大值(忽略 null)", () => {
expect(metricMax([0.12, null, 0.2])).toBe(0.2);
});
it("全缺失 / 全零给极小正数(坐标系不除零)", () => {
expect(metricMax([null, null])).toBe(1e-9);
expect(metricMax([0, 0])).toBe(1e-9);
});
});
describe("modelSeries / seriesValues(曲线按模型分系列)", () => {
const mixed = [
{ score: 6, provider: "deepseek", modelId: "deepseek-v4-flash" },
{ score: 7 }, // legacy record: no model tagged -> trailing gray series
{ score: 7.5, provider: "deepseek", modelId: "deepseek-v4-pro" },
{ score: 8.5, provider: "deepseek", modelId: "deepseek-v4-pro" },
];
it("按 (provider, modelId) 分组,首次出现顺序排列,未标注归末尾无名系列", () => {
const series = modelSeries(mixed);
expect(series.map((s) => s.modelId)).toEqual([
"deepseek-v4-flash",
"deepseek-v4-pro",
undefined,
]);
expect(series.map((s) => s.indices)).toEqual([[0], [2, 3], [1]]);
expect(series[2]!.key).toBe("");
});
it("同名 modelId 跨 provider 各成一系列(成对分组,不做拼接语义)", () => {
const dup = [
{ score: 1, provider: "moonshot", modelId: "kimi-k2.6" },
{ score: 2, provider: "siliconflow", modelId: "kimi-k2.6" },
];
const series = modelSeries(dup);
expect(series).toHaveLength(2);
expect(series.map((s) => s.provider)).toEqual(["moonshot", "siliconflow"]);
});
it("seriesValues:非本系列下标为 null(跳点),沿用全局时间轴", () => {
const series = modelSeries(mixed);
expect(seriesValues(mixed, series[1]!, "score")).toEqual([null, null, 7.5, 8.5]);
expect(seriesValues(mixed, series[2]!, "score")).toEqual([null, 7, null, null]);
});
it("全部未标注:只有一条无名系列(旧数据单系列照画)", () => {
const series = modelSeries([{}, {}]);
expect(series).toHaveLength(1);
expect(series[0]!.key).toBe("");
expect(series[0]!.indices).toEqual([0, 1]);
});
});
+31
View File
@@ -0,0 +1,31 @@
/**
* context.ts unit tests: context window cap resolution (used for the ring
* cap display) — positive numbers are used as-is, otherwise fall back to
* the default 128000.
*/
import { describe, expect, it } from "vitest";
import { DEFAULT_CONTEXT_WINDOW, resolveContextWindow } from "../src/lib/context";
describe("resolveContextWindow", () => {
it("正数原样返回", () => {
expect(resolveContextWindow(200000)).toBe(200000);
expect(resolveContextWindow(1)).toBe(1);
});
it("未配置(undefined / null)回退缺省 128000", () => {
expect(resolveContextWindow(undefined)).toBe(128000);
expect(resolveContextWindow(null)).toBe(DEFAULT_CONTEXT_WINDOW);
});
it("Trace 的 model_context_window 可能是字符串:数字串解析、`unknown` 回退", () => {
expect(resolveContextWindow("200000")).toBe(200000);
expect(resolveContextWindow("unknown")).toBe(128000);
expect(resolveContextWindow("")).toBe(128000);
});
it("非正数(0 / 负数 / NaN)回退缺省", () => {
expect(resolveContextWindow(0)).toBe(128000);
expect(resolveContextWindow(-5)).toBe(128000);
expect(resolveContextWindow(Number.NaN)).toBe(128000);
});
});
+175
View File
@@ -0,0 +1,175 @@
/**
* Draft cache parsing/validation and storage access (the auto-cache
* foundation for draft-view):
* - parseDraft validates field by field — localStorage may be corrupted by
* external code, so bad JSON / non-object / invalid fields are always
* discarded instead of crashing the page;
* - load/save/clear are isolated by "user x Project/Session" (#68: switching
* accounts in the same browser must not leak drafts across users); storage
* errors (quota/private mode) are swallowed silently.
*/
import { describe, expect, it } from "vitest";
import {
clearDraft,
draftKey,
loadDraft,
parseDraft,
saveDraft,
sessionDraftKey,
} from "../src/features/chat/draft-cache";
import type { DraftStorage } from "../src/features/chat/draft-cache";
/** In-memory storage (vitest runs in a Node environment, no localStorage). */
function memStorage(): DraftStorage & { map: Map<string, string> } {
const map = new Map<string, string>();
return {
map,
getItem: (k) => map.get(k) ?? null,
setItem: (k, v) => void map.set(k, v),
removeItem: (k) => void map.delete(k),
};
}
describe("parseDraft(逐字段校验)", () => {
it("null / 空串 / 坏 JSON / 非对象一律给空草稿", () => {
expect(parseDraft(null)).toEqual({});
expect(parseDraft("")).toEqual({});
expect(parseDraft("{not json")).toEqual({});
expect(parseDraft("42")).toEqual({});
expect(parseDraft('"str"')).toEqual({});
expect(parseDraft("null")).toEqual({});
expect(parseDraft("[1,2]")).toEqual({});
});
it("合法字段逐项透传(模型为成对引用)", () => {
const raw = JSON.stringify({
text: "帮我写个脚本",
agentId: "default_agent",
workspace: "/srv/repo",
approvalMode: "read-only",
modelRef: { provider: "anthropic", modelId: "claude-opus-4-8" },
handoffAgentId: "agent_helper",
skills: ["agent-creation", "penguin-sdk"],
});
expect(parseDraft(raw)).toEqual({
text: "帮我写个脚本",
agentId: "default_agent",
workspace: "/srv/repo",
approvalMode: "read-only",
modelRef: { provider: "anthropic", modelId: "claude-opus-4-8" },
handoffAgentId: "agent_helper",
skills: ["agent-creation", "penguin-sdk"],
});
});
it("类型不对的字段丢弃,其余保留", () => {
const raw = JSON.stringify({
text: 123,
agentId: null,
workspace: { path: "/x" },
approvalMode: "read-only",
modelRef: ["m"],
handoffAgentId: 7,
});
expect(parseDraft(raw)).toEqual({ approvalMode: "read-only" });
});
it("旧格式的字符串 modelId 与半个引用一律丢弃(未发布,不做迁移)", () => {
expect(parseDraft(JSON.stringify({ modelId: "claude-opus-4-8" }))).toEqual({});
expect(parseDraft(JSON.stringify({ modelRef: { modelId: "claude-opus-4-8" } }))).toEqual({});
expect(parseDraft(JSON.stringify({ modelRef: { provider: "anthropic" } }))).toEqual({});
});
it("approvalMode 只收四个合法值", () => {
expect(parseDraft(JSON.stringify({ approvalMode: "yolo" }))).toEqual({});
for (const m of ["always-ask", "read-only", "allow-all", "deny-all"]) {
expect(parseDraft(JSON.stringify({ approvalMode: m }))).toEqual({ approvalMode: m });
}
});
it("未知字段不透传", () => {
const out = parseDraft(JSON.stringify({ text: "hi", evil: "x" }));
expect(out).toEqual({ text: "hi" });
});
it("skills:非数组丢弃,数组内非字符串元素过滤,过滤后为空整个字段丢弃", () => {
// Non-arrays are always discarded
expect(parseDraft(JSON.stringify({ skills: "agent-creation" }))).toEqual({});
expect(parseDraft(JSON.stringify({ skills: { 0: "x" } }))).toEqual({});
expect(parseDraft(JSON.stringify({ skills: 42 }))).toEqual({});
// Validate each element individually: strings are kept, everything else is filtered out
expect(parseDraft(JSON.stringify({ skills: ["a", 1, null, {}, "b", false] }))).toEqual({
skills: ["a", "b"],
});
// Empty array / empty after filtering -> the whole field is dropped
expect(parseDraft(JSON.stringify({ skills: [] }))).toEqual({});
expect(parseDraft(JSON.stringify({ skills: [7, null] }))).toEqual({});
});
});
describe("load / save / clear(按键隔离,异常静默)", () => {
it("保存后可等值读回;Project 草稿与 Session 草稿互不影响", () => {
const s = memStorage();
saveDraft(
draftKey("user-a1", "project-a"),
{
text: "草稿 A",
modelRef: { provider: "deepseek", modelId: "deepseek-v4-pro" },
skills: ["agent-creation"],
},
s,
);
saveDraft(sessionDraftKey("user-a1", "session-1"), { text: "会话草稿" }, s);
expect(loadDraft(draftKey("user-a1", "project-a"), s)).toEqual({
text: "草稿 A",
modelRef: { provider: "deepseek", modelId: "deepseek-v4-pro" },
skills: ["agent-creation"],
});
expect(loadDraft(sessionDraftKey("user-a1", "session-1"), s)).toEqual({ text: "会话草稿" });
// Lock down the key format (e2e accesses it as a literal; ids are all
// <prefix>-<hex> with no ".", so the key space never collides).
expect(draftKey("user-a1", "project-a")).toBe("penguin.chatDraft.user-a1.project-a");
expect(sessionDraftKey("user-a1", "session-1")).toBe(
"penguin.chatDraft.session.user-a1.session-1",
);
expect(s.map.size).toBe(2);
});
it("同一 Project/Session 的草稿按用户隔离:互相读不到、写不覆盖(#68)", () => {
const s = memStorage();
saveDraft(draftKey("user-a1", "project-a"), { text: "A 的机密草稿" }, s);
saveDraft(sessionDraftKey("user-a1", "session-1"), { text: "A 的会话草稿" }, s);
// Switch accounts (same browser): B reading the same Project/Session only gets an empty draft.
expect(loadDraft(draftKey("user-b2", "project-a"), s)).toEqual({});
expect(loadDraft(sessionDraftKey("user-b2", "session-1"), s)).toEqual({});
// B saves its own draft: coexists with A's, neither overwrites the other.
saveDraft(draftKey("user-b2", "project-a"), { text: "B 的草稿" }, s);
expect(loadDraft(draftKey("user-a1", "project-a"), s)).toEqual({ text: "A 的机密草稿" });
expect(loadDraft(draftKey("user-b2", "project-a"), s)).toEqual({ text: "B 的草稿" });
});
it("clear 后读回空草稿", () => {
const s = memStorage();
saveDraft(draftKey("user-a1", "project-a"), { text: "要清掉" }, s);
clearDraft(draftKey("user-a1", "project-a"), s);
expect(loadDraft(draftKey("user-a1", "project-a"), s)).toEqual({});
expect(s.map.size).toBe(0);
});
it("storage 抛异常(配额/隐私模式):save 不抛、load 给空草稿", () => {
const broken: DraftStorage = {
getItem: () => {
throw new Error("SecurityError");
},
setItem: () => {
throw new Error("QuotaExceededError");
},
removeItem: () => {
throw new Error("SecurityError");
},
};
expect(() => saveDraft(draftKey("u", "p"), { text: "x" }, broken)).not.toThrow();
expect(() => clearDraft(draftKey("u", "p"), broken)).not.toThrow();
expect(loadDraft(draftKey("u", "p"), broken)).toEqual({});
});
});
+117
View File
@@ -0,0 +1,117 @@
/**
* file-path.ts unit tests: whether inline code in message text looks like a file path,
* and all branches of normalizing body paths to a Workspace-relative path (toWorkspaceRelative).
*/
import { describe, expect, it } from "vitest";
import { isFilePathLike, toWorkspaceRelative } from "../src/lib/file-path";
describe("isFilePathLike", () => {
it("相对路径 + 图片扩展名 → 命中", () => {
expect(isFilePathLike("./foo/bar.png")).toBe(true);
});
it("无斜杠的裸文件名 + 已知扩展名 → 命中", () => {
expect(isFilePathLike("report.pdf")).toBe(true);
});
it("多级相对路径 + 代码扩展名 → 命中", () => {
expect(isFilePathLike("src/lib/format.ts")).toBe(true);
});
it("前后空白会被裁剪,不影响命中", () => {
expect(isFilePathLike(" ./agentmind-architecture-minimal.png ")).toBe(true);
});
it("裸词(无扩展名)→ 不命中", () => {
expect(isFilePathLike("hello")).toBe(false);
});
it("未知扩展名 → 不命中", () => {
expect(isFilePathLike("archive.xyz")).toBe(false);
});
it("版本号(数字扩展名)→ 不命中", () => {
expect(isFilePathLike("v1.2")).toBe(false);
});
it("URL(含 scheme)→ 不命中,即使结尾像扩展名", () => {
expect(isFilePathLike("https://example.com/report.pdf")).toBe(false);
});
it("裸域名(www 前缀)→ 不命中", () => {
expect(isFilePathLike("www.example.com")).toBe(false);
});
it("含空格的长句子(句中带点)→ 不命中", () => {
expect(isFilePathLike("See the report.pdf attached in this message.")).toBe(false);
});
it("空字符串 → 不命中", () => {
expect(isFilePathLike("")).toBe(false);
});
});
describe("toWorkspaceRelative", () => {
const WS = "/home/user/workspaces/tmp-1";
it("绝对路径命中 Workspace 前缀 → 剥前缀返回相对路径", () => {
expect(toWorkspaceRelative(`${WS}/report.pdf`, WS)).toBe("report.pdf");
expect(toWorkspaceRelative(`${WS}/sub/dir/a.txt`, WS)).toBe("sub/dir/a.txt");
});
it("绝对路径未命中前缀 / 等于 Workspace 本身 / 无 Workspace → null", () => {
expect(toWorkspaceRelative("/etc/passwd.txt", WS)).toBe(null);
// Sibling directory shares the prefix string but differs by a separator: not a match.
expect(toWorkspaceRelative(`${WS}-other/a.txt`, WS)).toBe(null);
expect(toWorkspaceRelative(WS, WS)).toBe(null);
expect(toWorkspaceRelative("/a/b.txt", null)).toBe(null);
expect(toWorkspaceRelative("/a/b.txt", "")).toBe(null);
});
it("绝对路径剥前缀后继续词法归一(含 .. 越根 → null)", () => {
expect(toWorkspaceRelative(`${WS}/sub/../a.txt`, WS)).toBe("a.txt");
expect(toWorkspaceRelative(`${WS}/../escape.txt`, WS)).toBe(null);
});
it("Windows 风格 Workspace:盘符/反斜杠绝对路径剥前缀并统一转 '/'", () => {
const win = "C:\\Users\\me\\ws";
expect(toWorkspaceRelative("C:\\Users\\me\\ws\\sub\\a.txt", win)).toBe("sub/a.txt");
expect(toWorkspaceRelative("D:\\other\\a.txt", win)).toBe(null);
expect(toWorkspaceRelative("C:\\Users\\me\\ws", win)).toBe(null);
});
it("POSIX 文件名里的反斜杠不做全局替换(仅 Windows 前缀命中时转换)", () => {
expect(toWorkspaceRelative("dir\\name.txt", WS)).toBe("dir\\name.txt");
});
it("相对路径词法归一:./ 与空段剔除、.. 弹栈", () => {
expect(toWorkspaceRelative("./foo/bar.png", WS)).toBe("foo/bar.png");
expect(toWorkspaceRelative("a/./b//c.txt", WS)).toBe("a/b/c.txt");
expect(toWorkspaceRelative("a/../b.txt", WS)).toBe("b.txt");
});
it("相对路径 .. 越出 Workspace 根 → null", () => {
expect(toWorkspaceRelative("../a.txt", WS)).toBe(null);
expect(toWorkspaceRelative("a/../../b.txt", WS)).toBe(null);
});
it("~ 开头(家目录)→ null", () => {
expect(toWorkspaceRelative("~/docs/a.txt", WS)).toBe(null);
});
it("纯文件名原样返回;Workspace 为 null 的相对路径照常归一", () => {
expect(toWorkspaceRelative("report.pdf", WS)).toBe("report.pdf");
expect(toWorkspaceRelative("sub/a.txt", null)).toBe("sub/a.txt");
});
it("空 / 纯空白 / 归一后为空('.')/ 超长(>512)→ null", () => {
expect(toWorkspaceRelative("", WS)).toBe(null);
expect(toWorkspaceRelative(" ", WS)).toBe(null);
expect(toWorkspaceRelative(".", WS)).toBe(null);
expect(toWorkspaceRelative(`a/${"x".repeat(512)}.txt`, WS)).toBe(null);
});
it("前后空白先裁剪再归一", () => {
expect(toWorkspaceRelative(` ${WS}/a.txt `, WS)).toBe("a.txt");
});
});
+207
View File
@@ -0,0 +1,207 @@
/**
* format.ts unit tests: Token/duration humanized abbreviations match the CLI convention.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
computeTps,
formatBytes,
formatDateTime,
formatMoney,
formatPercent,
formatRelativeDate,
formatRelativeDays,
formatTps,
humanizeDuration,
humanizeTokens,
signedDelta,
} from "../src/lib/format";
describe("humanizeTokens", () => {
it("小于 1000 原样输出", () => {
expect(humanizeTokens(0)).toBe("0");
expect(humanizeTokens(999)).toBe("999");
});
it("千位缩写并去掉多余的 .0", () => {
expect(humanizeTokens(1000)).toBe("1k");
expect(humanizeTokens(1234)).toBe("1.2k");
expect(humanizeTokens(4000)).toBe("4k");
});
it("百万缩写", () => {
expect(humanizeTokens(1_500_000)).toBe("1.5M");
expect(humanizeTokens(2_000_000)).toBe("2M");
});
it("负数保留符号(上下文回落)", () => {
expect(humanizeTokens(-1200)).toBe("-1.2k");
expect(humanizeTokens(-500)).toBe("-500");
});
});
describe("humanizeDuration", () => {
it("毫秒/秒/分钟折算", () => {
expect(humanizeDuration(820)).toBe("820ms");
expect(humanizeDuration(2300)).toBe("2.3s");
expect(humanizeDuration(5100)).toBe("5.1s");
expect(humanizeDuration(63000)).toBe("1m3s");
expect(humanizeDuration(130000)).toBe("2m10s");
});
});
describe("signedDelta", () => {
it("非负补 +,负数自带 -", () => {
expect(signedDelta("1k")).toBe("+1k");
expect(signedDelta("-1k")).toBe("-1k");
expect(signedDelta("0")).toBe("+0");
});
});
describe("formatMoney", () => {
it("无 pricing 显示 —", () => {
expect(formatMoney(null)).toBe("—");
expect(formatMoney(undefined)).toBe("—");
});
it("缺省按美元、按量级取小数位", () => {
expect(formatMoney(0)).toBe("$0");
expect(formatMoney(0.1234)).toBe("$0.1234");
expect(formatMoney(1.5)).toBe("$1.50");
expect(formatMoney(150)).toBe("$150");
});
it("人民币按 1:7 折算", () => {
expect(formatMoney(0, "CNY")).toBe("¥0");
expect(formatMoney(1, "CNY")).toBe("¥7.00");
expect(formatMoney(0.01, "CNY")).toBe("¥0.0700");
});
});
describe("formatPercent", () => {
it("四舍五入到整数百分比(缓存命中率)", () => {
expect(formatPercent(0)).toBe("0%");
expect(formatPercent(0.714)).toBe("71%");
expect(formatPercent(0.716)).toBe("72%");
expect(formatPercent(1)).toBe("100%");
});
it("null / 非有限值(输入为 0,命中率无从谈起)显示 —", () => {
expect(formatPercent(null)).toBe("—");
expect(formatPercent(undefined)).toBe("—");
expect(formatPercent(Number.NaN)).toBe("—");
expect(formatPercent(Number.POSITIVE_INFINITY)).toBe("—");
});
});
describe("formatBytes", () => {
it("字节缩写", () => {
expect(formatBytes(812)).toBe("812B");
expect(formatBytes(3481)).toBe("3.4KB");
expect(formatBytes(2 * 1024 * 1024)).toBe("2MB");
});
});
describe("computeTps", () => {
it("输出 token ÷ LLM 秒数(已知量核对)", () => {
expect(computeTps(900, 3000)).toBe(300); // 900 / 3s
expect(computeTps(1500, 2000)).toBe(750); // 1500 / 2s
});
it("llmMs ≤ 0(无计时)返回 null,避免除零", () => {
expect(computeTps(900, 0)).toBeNull();
expect(computeTps(900, -5)).toBeNull();
});
});
describe("formatTps", () => {
it("小于 1000 保留一位小数(去掉多余的 .0)", () => {
expect(formatTps(42.53)).toBe("42.5 tok/s");
expect(formatTps(120)).toBe("120 tok/s");
expect(formatTps(300)).toBe("300 tok/s");
expect(formatTps(999.9)).toBe("999.9 tok/s");
});
it("不小于 1000 按 k / M 量级缩写(与 humanizeTokens 同口径)", () => {
expect(formatTps(1000)).toBe("1k tok/s");
expect(formatTps(37_783.4)).toBe("37.8k tok/s");
expect(formatTps(1_200_000)).toBe("1.2M tok/s");
});
it("null / 非有限值显示 —", () => {
expect(formatTps(null)).toBe("—");
expect(formatTps(undefined)).toBe("—");
expect(formatTps(Number.NaN)).toBe("—");
expect(formatTps(Number.POSITIVE_INFINITY)).toBe("—");
});
});
describe("formatRelativeDays", () => {
// Fix "now" to local 2026-07-15 12:00; day difference is computed by local calendar day.
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 6, 15, 12, 0, 0));
});
afterEach(() => {
vi.useRealTimers();
});
it("同日「今天」,与当天几点无关", () => {
expect(formatRelativeDays(new Date(2026, 6, 15, 0, 5).toISOString(), "zh")).toBe("今天");
expect(formatRelativeDays(new Date(2026, 6, 15, 23, 59).toISOString(), "en")).toBe("today");
});
it("隔日「昨天」,更早按日历日差「n 天前」(含跨月)", () => {
expect(formatRelativeDays(new Date(2026, 6, 14, 23, 0).toISOString(), "zh")).toBe("昨天");
expect(formatRelativeDays(new Date(2026, 6, 14, 1, 0).toISOString(), "en")).toBe("yesterday");
expect(formatRelativeDays(new Date(2026, 6, 10, 8, 0).toISOString(), "zh")).toBe("5 天前");
expect(formatRelativeDays(new Date(2026, 5, 5, 8, 0).toISOString(), "en")).toBe("40 days ago");
});
it("未来时间(时钟偏差)回落到绝对时间", () => {
const future = new Date(2026, 6, 20, 8, 5).toISOString();
expect(formatRelativeDays(future, "zh")).toBe(formatDateTime(future));
expect(formatRelativeDays(future, "zh")).toBe("2026-07-20 08:05");
});
it("解析失败原样返回", () => {
expect(formatRelativeDays("not-a-date", "zh")).toBe("not-a-date");
});
});
describe("formatRelativeDate(技能卡片的语义化更新时间)", () => {
// Fix "now" to local 2026-07-15 12:00 (same convention as formatRelativeDays).
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2026, 6, 15, 12, 0, 0));
});
afterEach(() => {
vi.useRealTimers();
});
it("同日「今天更新 / updated today」,与当天几点无关", () => {
expect(formatRelativeDate(new Date(2026, 6, 15, 0, 5).toISOString(), "zh")).toBe("今天更新");
expect(formatRelativeDate(new Date(2026, 6, 15, 23, 59).toISOString(), "en")).toBe(
"updated today",
);
});
it("隔日「昨天更新」,更早按日历日差「n 天前更新」(含跨月)", () => {
expect(formatRelativeDate(new Date(2026, 6, 14, 23, 0).toISOString(), "zh")).toBe("昨天更新");
expect(formatRelativeDate(new Date(2026, 6, 14, 1, 0).toISOString(), "en")).toBe(
"updated yesterday",
);
expect(formatRelativeDate(new Date(2026, 6, 10, 8, 0).toISOString(), "zh")).toBe("5 天前更新");
expect(formatRelativeDate(new Date(2026, 5, 5, 8, 0).toISOString(), "en")).toBe(
"updated 40 days ago",
);
});
it("未来时间(时钟偏差)回落到日期本身(不加「更新」措辞)", () => {
expect(formatRelativeDate("2026-07-20", "zh")).toBe("2026-07-20");
});
it("解析失败原样返回", () => {
expect(formatRelativeDate("not-a-date", "zh")).toBe("not-a-date");
expect(formatRelativeDate("", "en")).toBe("");
});
});
+20
View File
@@ -0,0 +1,20 @@
/**
* locale.tsx unit tests: device language resolution (language follows
* navigator.language when no stored preference exists; language/theme
* initialization does not depend on login state and also applies on the
* login page).
*/
import { describe, expect, it } from "vitest";
import { resolveSystemLocale } from "../src/state/locale";
describe("resolveSystemLocale(navigator.language → 界面语言)", () => {
it("zh 前缀(任意大小写与地区变体)→ zh", () => {
expect(resolveSystemLocale("zh-CN")).toBe("zh");
expect(resolveSystemLocale("ZH-TW")).toBe("zh");
});
it("非中文或取不到 → 英文兜底", () => {
expect(resolveSystemLocale("en-US")).toBe("en");
expect(resolveSystemLocale(undefined)).toBe("en");
});
});
+125
View File
@@ -0,0 +1,125 @@
/**
* model-grouping.ts unit tests: search filtering (id / display name / provider name,
* case-insensitive) and grouping by provider — grouping reads the row's **provider field**
* directly ((provider, model_id) are stored as separate columns; id is never concatenated
* or split anywhere); built-in group order follows MODEL_PROVIDERS with custom last, and any
* provider not in the catalog becomes a custom-built group — each forms its own
* group, sorted by name and appended after custom; empty groups are hidden, except the
* custom group, which is always shown when there's no search query, hosting the generic
* "add model" entry point.
*/
import { describe, expect, it } from "vitest";
import { MODEL_PROVIDERS } from "@prismshadow/penguin-core/model-catalog";
import { groupModelRows, matchesQuery } from "../src/features/models/model-grouping";
import type { ModelRowLike } from "../src/features/models/model-grouping";
const rows: ModelRowLike[] = [
{ provider: "anthropic", modelId: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6" },
{ provider: "anthropic", modelId: "claude-opus-4-8", displayName: "Claude Opus 4.8" },
{ provider: "moonshot", modelId: "kimi-k2.6", displayName: "Kimi K2.6" },
{ provider: "custom", modelId: "my-proxy-model" },
{ provider: "unknown-vendor", modelId: "weird-model" }, // provider not in the catalog → custom-built group
];
describe("matchesQuery", () => {
it("空查询恒真", () => {
expect(matchesQuery(rows[0]!, "")).toBe(true);
expect(matchesQuery(rows[0]!, " ")).toBe(true);
});
it("按 model_id / 展示名 / 厂商名匹配,大小写不敏感", () => {
expect(matchesQuery(rows[0]!, "SONNET")).toBe(true); // display name
expect(matchesQuery(rows[0]!, "claude-sonnet")).toBe(true); // upstream id
expect(matchesQuery(rows[0]!, "anthropic")).toBe(true); // provider
expect(matchesQuery(rows[2]!, "moonshot")).toBe(true); // provider label includes Moonshot (Kimi)
expect(matchesQuery(rows[0]!, "gemini")).toBe(false);
});
it("无展示名的自定义模型按 id 与 Custom 组名匹配", () => {
expect(matchesQuery(rows[3]!, "proxy")).toBe(true);
expect(matchesQuery(rows[3]!, "custom")).toBe(true);
// Custom-built groups are searchable by their group name (raw provider value); no longer folded into the Custom bucket.
expect(matchesQuery(rows[4]!, "unknown-vendor")).toBe(true);
expect(matchesQuery(rows[4]!, "custom")).toBe(false);
});
});
describe("groupModelRows", () => {
it("按条目 provider 分组(顺序沿 MODEL_PROVIDERS),custom 末位,自建分组附加其后", () => {
const groups = groupModelRows(rows, "");
expect(groups.map((g) => g.provider.id)).toEqual([
"anthropic",
"moonshot",
"custom",
"unknown-vendor",
]);
expect(groups[0]!.rows.map((r) => r.modelId)).toEqual(["claude-sonnet-4-6", "claude-opus-4-8"]);
expect(groups[2]!.rows.map((r) => r.modelId)).toEqual(["my-proxy-model"]);
// Custom-built group: synthesized provider info — label is the group name, OpenAI-protocol semantics (env falls back to OPENAI_*).
expect(groups[3]!.provider.label).toBe("unknown-vendor");
expect(groups[3]!.provider.envKey).toBe("OPENAI_API_KEY");
expect(groups[3]!.rows.map((r) => r.modelId)).toEqual(["weird-model"]);
// Group order matches the MODEL_PROVIDERS definition: DeepSeek first, the two gateways right after,
// Google Gemini before Anthropic, custom last.
expect(MODEL_PROVIDERS.map((p) => p.id)).toEqual([
"deepseek",
"openrouter",
"siliconflow",
"google",
"anthropic",
"openai",
"zhipu",
"moonshot",
"custom",
]);
expect(MODEL_PROVIDERS.find((p) => p.id === "siliconflow")!.label).toBe("SiliconFlow");
});
it("custom 组在无搜索词时恒展示(空组也返回,承载新增入口)", () => {
const vendorOnly: ModelRowLike[] = [{ provider: "moonshot", modelId: "kimi-k2.6" }];
const groups = groupModelRows(vendorOnly, "");
expect(groups.map((g) => g.provider.id)).toEqual(["moonshot", "custom"]);
expect(groups[1]!.rows).toEqual([]);
// Empty groups for other providers stay hidden (only moonshot and custom appear above).
// With a search query, the empty custom group no longer appears.
expect(groupModelRows(vendorOnly, "kimi").map((g) => g.provider.id)).toEqual(["moonshot"]);
});
it("搜索时只保留命中行,空组不返回", () => {
const groups = groupModelRows(rows, "kimi");
expect(groups).toHaveLength(1);
expect(groups[0]!.provider.id).toBe("moonshot");
expect(groups[0]!.rows.map((r) => r.modelId)).toEqual(["kimi-k2.6"]);
expect(groupModelRows(rows, "no-such-model")).toEqual([]);
});
it("上游 id 自身含 `/`(网关模型)只是普通字符:分组只看 provider 字段", () => {
const gateway: ModelRowLike[] = [{ provider: "openrouter", modelId: "xiaomi/mimo-v2.5" }];
const groups = groupModelRows(gateway, "");
expect(groups.map((g) => g.provider.id)).toEqual(["openrouter", "custom"]);
expect(groups[0]!.rows[0]!.modelId).toBe("xiaomi/mimo-v2.5");
expect(matchesQuery(gateway[0]!, "mimo")).toBe(true);
});
it("同名 model_id 在不同 provider 下并存:各归各组,互不合并", () => {
const dup: ModelRowLike[] = [
{ provider: "moonshot", modelId: "kimi-k2.6" },
{ provider: "siliconflow", modelId: "kimi-k2.6" },
];
const groups = groupModelRows(dup, "");
expect(groups.map((g) => g.provider.id)).toEqual(["siliconflow", "moonshot", "custom"]);
expect(groups[0]!.rows).toHaveLength(1);
expect(groups[1]!.rows).toHaveLength(1);
});
it("多个自建分组按名排序附加在 custom 之后", () => {
const mixed: ModelRowLike[] = [
{ provider: "zeta-lab", modelId: "z-1" },
{ provider: "alpha-proxy", modelId: "a-1" },
];
const groups = groupModelRows(mixed, "");
expect(groups.map((g) => g.provider.id)).toEqual(["custom", "alpha-proxy", "zeta-lab"]);
// Search matches a custom-built group's name: only that group is kept.
expect(groupModelRows(mixed, "zeta").map((g) => g.provider.id)).toEqual(["zeta-lab"]);
});
});
+163
View File
@@ -0,0 +1,163 @@
/**
* Pure logic for the model config dialog:
* - Numeric input filtering: the context window field accepts digits only;
* the price field accepts digits and at most one decimal point (any other
* characters from paste/IME input are stripped and never reach the form);
* - DTO -> row edit state (toRow): provider and modelId are both plain entry
* fields with no prefix parsing; the loaded identity (original) is a paired
* reference;
* - Ownership of the default/vision-agent model pointers after save
* (nextPointers): always compared as pairs; renaming (either provider or
* model_id changes) moves the pointer along.
*/
import { describe, expect, it } from "vitest";
import {
decimalOnly,
digitsOnly,
nextPointers,
rowRef,
toRow,
} from "../src/features/models/models-page";
describe("digitsOnly(上下文窗口)", () => {
it("只留数字", () => {
expect(digitsOnly("200000")).toBe("200000");
expect(digitsOnly("20e5")).toBe("205");
expect(digitsOnly("200,000 tokens")).toBe("200000");
expect(digitsOnly("-3.5")).toBe("35");
expect(digitsOnly("abc")).toBe("");
});
});
describe("decimalOnly(价格)", () => {
it("留数字与至多一个小数点", () => {
expect(decimalOnly("3.75")).toBe("3.75");
expect(decimalOnly("$3.75")).toBe("3.75");
expect(decimalOnly("3.7.5")).toBe("3.75");
expect(decimalOnly("1..2.3")).toBe("1.23");
expect(decimalOnly(".5")).toBe(".5");
expect(decimalOnly("-1e3")).toBe("13");
expect(decimalOnly("abc")).toBe("");
});
});
describe("toRow(DTO → 行编辑态)", () => {
it("provider 与 modelId 都是条目字段(零拆解),载入身份为成对引用", () => {
const row = toRow({
provider: "anthropic",
modelId: "claude-sonnet-4-6",
displayName: "Claude Sonnet 4.6",
isDefault: false,
});
expect(row.provider).toBe("anthropic");
expect(row.modelId).toBe("claude-sonnet-4-6");
expect(row.original).toEqual({ provider: "anthropic", modelId: "claude-sonnet-4-6" });
expect(rowRef(row)).toEqual({ provider: "anthropic", modelId: "claude-sonnet-4-6" });
});
it("不在目录清单的 provider 原样保留(展示层才归 custom 桶)", () => {
const row = toRow({ provider: "myproxy", modelId: "claude-sonnet-4-6", isDefault: false });
expect(row.provider).toBe("myproxy");
expect(row.modelId).toBe("claude-sonnet-4-6");
expect(row.original).toEqual({ provider: "myproxy", modelId: "claude-sonnet-4-6" });
});
it("上游 id 自身可含 `/`(网关模型):仍是完整的 model_id,不被误当分组", () => {
const row = toRow({ provider: "openrouter", modelId: "xiaomi/mimo-v2.5", isDefault: false });
expect(row.provider).toBe("openrouter");
expect(row.modelId).toBe("xiaomi/mimo-v2.5");
});
});
describe("nextPointers(保存后默认/视觉代理模型指向谁;一律成对)", () => {
const mA = { provider: "custom", modelId: "m-a" };
const mANew = { provider: "custom", modelId: "m-a-new" };
const mB = { provider: "custom", modelId: "m-b" };
const base = { action: "save" as const, defaultModel: mA, visionModel: mB };
it("改名时指针跟着改名走(否则提交的旧引用已不在 models 内,服务端 400)", () => {
// Renaming the current default model.
expect(nextPointers({ ...base, editing: mA, ref: mANew })).toEqual({
defaultModel: mANew,
visionModel: mB,
});
// Renaming the current vision-agent model.
const mBNew = { provider: "custom", modelId: "m-b-new" };
expect(nextPointers({ ...base, editing: mB, ref: mBNew })).toEqual({
defaultModel: mA,
visionModel: mBNew,
});
// Same model is both default and vision agent: both pointers move together.
expect(nextPointers({ ...base, editing: mA, ref: mANew, visionModel: mA })).toEqual({
defaultModel: mANew,
visionModel: mANew,
});
});
it("只换分组(model_id 不变)同样是改名:指针跟着新 provider 走", () => {
const moved = { provider: "openai", modelId: "m-a" };
expect(nextPointers({ ...base, editing: mA, ref: moved })).toEqual({
defaultModel: moved,
visionModel: mB,
});
});
it("不改名、改别的模型:指针原样不动", () => {
expect(nextPointers({ ...base, editing: mA, ref: mA })).toEqual({
defaultModel: mA,
visionModel: mB,
});
const mC = { provider: "custom", modelId: "m-c" };
const mCNew = { provider: "custom", modelId: "m-c-new" };
expect(nextPointers({ ...base, editing: mC, ref: mCNew })).toEqual({
defaultModel: mA,
visionModel: mB,
});
});
it("同名 model_id 双 provider 并存:只有成对相等的指针才跟随", () => {
// Default pointer points to openai/m-a, but the edit renames custom/m-a:
// same modelId, different provider — the pointer must not be changed.
const openaiA = { provider: "openai", modelId: "m-a" };
expect(
nextPointers({
action: "save",
editing: mA,
ref: mANew,
defaultModel: openaiA,
visionModel: undefined,
}),
).toEqual({ defaultModel: openaiA, visionModel: undefined });
});
it("设为默认 / 设为视觉代理:指针指向本模型", () => {
const mC = { provider: "custom", modelId: "m-c" };
expect(nextPointers({ ...base, editing: mC, ref: mC, action: "setDefault" })).toEqual({
defaultModel: mC,
visionModel: mB,
});
expect(nextPointers({ ...base, editing: mC, ref: mC, action: "setVisionModel" })).toEqual({
defaultModel: mA,
visionModel: mC,
});
});
it("新增的第一个模型(此前无默认)自动成为默认模型", () => {
const first = { provider: "custom", modelId: "m-first" };
expect(
nextPointers({
editing: null,
ref: first,
action: "save",
defaultModel: undefined,
visionModel: undefined,
}),
).toEqual({ defaultModel: first, visionModel: undefined });
// When a default already exists, a newly added model does not take it over.
const mNew = { provider: "custom", modelId: "m-new" };
expect(nextPointers({ ...base, editing: null, ref: mNew })).toEqual({
defaultModel: mA,
visionModel: mB,
});
});
});
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { nearestSnap, project, rubberband } from "../src/lib/sheet-physics";
describe("project", () => {
it("零速度零位移", () => {
expect(project(0)).toBe(0);
});
it("符合指数衰减投影公式(1000px/s @ 0.998 ≈ 499px)", () => {
expect(project(1000, 0.998)).toBeCloseTo(499, 0);
expect(project(-1000, 0.998)).toBeCloseTo(-499, 0);
});
it("减速率越低,投影越短(0.99 比 0.998 更快停下)", () => {
expect(Math.abs(project(1000, 0.99))).toBeLessThan(Math.abs(project(1000, 0.998)));
});
});
describe("rubberband", () => {
it("越界越多显示越多,但增量递减(渐进阻尼)", () => {
const d = 800;
const r50 = rubberband(50, d);
const r100 = rubberband(100, d);
const r150 = rubberband(150, d);
expect(r100).toBeGreaterThan(r50);
expect(r150).toBeGreaterThan(r100);
expect(r100 - r50).toBeGreaterThan(r150 - r100);
});
it("显示越界量恒小于实际越界量", () => {
expect(rubberband(100, 800)).toBeLessThan(100);
expect(rubberband(1, 800)).toBeLessThan(1);
});
});
describe("nearestSnap", () => {
it("就近吸附", () => {
const snaps = [0, 300, 800] as const;
expect(nearestSnap(120, snaps)).toBe(0);
expect(nearestSnap(180, snaps)).toBe(300);
expect(nearestSnap(540, snaps)).toBe(300);
expect(nearestSnap(700, snaps)).toBe(800);
});
it("恰在中点时取先出现的候选", () => {
expect(nearestSnap(150, [0, 300])).toBe(0);
});
});
+67
View File
@@ -0,0 +1,67 @@
/**
* sanitizeSkillIcon unit tests: security checks before inline rendering of a
* custom Skill icon (raw icon.svg content) — anything containing <script,
* event attributes (on*=), foreignObject, or any href attribute (not limited
* to xlink:, case-insensitive, quoted/unquoted forms) is rejected (falls back
* to the default book icon); a non-<svg> root / missing content is also
* rejected; a clean line-art SVG passes through as-is (after trim).
*/
import { describe, expect, it } from "vitest";
import { sanitizeSkillIcon } from "../src/features/skills/skill-icon";
const CLEAN =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">' +
'<path d="M4 4h16v16H4z" /></svg>';
describe("sanitizeSkillIcon", () => {
it("干净的 SVG 放行(trim 后原样返回,供内联渲染继承 currentColor)", () => {
expect(sanitizeSkillIcon(CLEAN)).toBe(CLEAN);
expect(sanitizeSkillIcon(`\n ${CLEAN}\n`)).toBe(CLEAN);
});
it("缺失 / 空串 / 空白回退 null", () => {
expect(sanitizeSkillIcon(undefined)).toBeNull();
expect(sanitizeSkillIcon("")).toBeNull();
expect(sanitizeSkillIcon(" ")).toBeNull();
});
it("非 <svg> 根拒绝(含伪装成文本的任意 HTML)", () => {
expect(sanitizeSkillIcon("<div>not svg</div>")).toBeNull();
expect(sanitizeSkillIcon("plain text")).toBeNull();
expect(sanitizeSkillIcon(`<img src=x>${CLEAN}`)).toBeNull();
// <svgfoo…> is not an <svg> root (the tag name must end with whitespace or >).
expect(sanitizeSkillIcon('<svgfoo viewBox="0 0 24 24"></svgfoo>')).toBeNull();
});
it("含 <script 拒绝(大小写不敏感)", () => {
expect(sanitizeSkillIcon("<svg><script>alert(1)</script></svg>")).toBeNull();
expect(sanitizeSkillIcon('<svg><SCRIPT href="x"/></svg>')).toBeNull();
});
it("含事件属性(on*=)拒绝", () => {
expect(sanitizeSkillIcon('<svg onload="alert(1)"><path d="M0 0" /></svg>')).toBeNull();
expect(sanitizeSkillIcon('<svg><path d="M0 0" onclick="x()" /></svg>')).toBeNull();
expect(sanitizeSkillIcon('<svg><path d="M0 0" ONLOAD = "x()" /></svg>')).toBeNull();
});
it("含 foreignObject / xlink:href 拒绝", () => {
expect(sanitizeSkillIcon("<svg><foreignObject><div>x</div></foreignObject></svg>")).toBeNull();
expect(sanitizeSkillIcon('<svg><use xlink:href="#evil" /></svg>')).toBeNull();
});
it("任何 href 属性拒绝(不限 xlink:,大小写不敏感,单双引号/无引号形态)", () => {
expect(
sanitizeSkillIcon('<svg><a href="javascript:alert(1)"><path d="M0 0" /></a></svg>'),
).toBeNull();
expect(sanitizeSkillIcon('<svg><use HREF="#evil" /></svg>')).toBeNull();
expect(sanitizeSkillIcon("<svg><use href='#evil' /></svg>")).toBeNull();
expect(sanitizeSkillIcon("<svg><a href=javascript:alert(1)>x</a></svg>")).toBeNull();
expect(sanitizeSkillIcon('<svg><use HREF = "#evil" /></svg>')).toBeNull();
});
it("路径数据里的坐标不误伤(stroke=none、d 属性带负数/小数照常放行)", () => {
const icon =
'<svg viewBox="0 0 24 24"><path d="M2.5 3h6a4 4 0 0 1 4 4v14" fill="none" /></svg>';
expect(sanitizeSkillIcon(icon)).toBe(icon);
});
});
+176
View File
@@ -0,0 +1,176 @@
/**
* Skill UI-language text and slash command assembly (pure functions shared by
* chat-input and the skill library page):
* - localizedText: uses the Chinese value when locale is zh and it's non-empty, otherwise
* falls back to English (an empty-string Chinese value counts as missing);
* - localizedShortText: prefers the short description (language takes priority over
* length), falling back to the full description when missing;
* - skillSlashItems: installed skills -> `/<skill_name>` command items, preferring the
* short description in the UI language;
* - filterSkills: search filter for the skill dropdown (matches name and localized
* description, case-insensitive);
* - skillsAutoMessage / quickInvokeText: auto-invoke text and quick-invoke prefill text
* (zh/en dictionaries).
*/
import { describe, expect, it } from "vitest";
import type { SkillMetadataItem } from "@prismshadow/penguin-server/api";
import {
filterSkills,
localizedShortText,
localizedText,
skillSlashItems,
} from "../src/features/chat/skill-use";
import { zh } from "../src/lib/strings";
import { en } from "../src/lib/strings-en";
describe("localizedText(界面语言取文案)", () => {
it("zh 优先中文字段", () => {
expect(localizedText("zh", "Create agents", "创建 Agent")).toBe("创建 Agent");
});
it("zh 但中文缺省(undefined / 空串)回退英文", () => {
expect(localizedText("zh", "Create agents")).toBe("Create agents");
expect(localizedText("zh", "Create agents", "")).toBe("Create agents");
});
it("en 恒用英文(即使有中文字段)", () => {
expect(localizedText("en", "Create agents", "创建 Agent")).toBe("Create agents");
});
});
describe("localizedShortText(短描述优先,缺失回退完整描述)", () => {
const full = {
description: "Create agents from requirements",
shortDescription: "Create agents",
shortDescriptionZh: "创建 Agent",
};
it("双语短描述齐全:按界面语言取短描述", () => {
expect(localizedShortText("zh", full)).toBe("创建 Agent");
expect(localizedShortText("en", full)).toBe("Create agents");
});
it("zh 短中文缺失时回退短英文,再回退完整英文(完整描述仅英文)", () => {
expect(localizedShortText("zh", { ...full, shortDescriptionZh: undefined })).toBe(
"Create agents",
);
expect(
localizedShortText("zh", {
description: "Create agents from requirements",
}),
).toBe("Create agents from requirements");
});
it("en:短描述缺失(含空串)回退完整描述", () => {
expect(localizedShortText("en", { ...full, shortDescription: undefined })).toBe(
"Create agents from requirements",
);
expect(localizedShortText("en", { ...full, shortDescription: "" })).toBe(
"Create agents from requirements",
);
});
});
describe("skillSlashItems(slash 技能命令项组装)", () => {
const skills: SkillMetadataItem[] = [
{
name: "agent-creation",
description: "Create agents from requirements",
version: 1,
updated: "2026-07-01",
},
{ name: "penguin-sdk", description: "Develop with the Penguin SDK", version: 2, updated: "" },
];
it("每个技能一项:cmd 为 /<skill_name>,desc 按界面语言(无中文短描述回退英文)", () => {
expect(skillSlashItems(skills, "zh")).toEqual([
{ name: "agent-creation", cmd: "/agent-creation", desc: "Create agents from requirements" },
{ name: "penguin-sdk", cmd: "/penguin-sdk", desc: "Develop with the Penguin SDK" },
]);
expect(skillSlashItems(skills, "en")).toEqual([
{ name: "agent-creation", cmd: "/agent-creation", desc: "Create agents from requirements" },
{ name: "penguin-sdk", cmd: "/penguin-sdk", desc: "Develop with the Penguin SDK" },
]);
});
it("空列表给空数组", () => {
expect(skillSlashItems([], "zh")).toEqual([]);
});
it("cmd 前缀匹配(slash 过滤口径):/agent-opt 命中 /agent-optimization", () => {
const items = skillSlashItems(
[{ name: "agent-optimization", description: "Optimize agents", version: 1, updated: "" }],
"en",
);
expect(items[0]!.cmd.startsWith("/agent-opt")).toBe(true);
});
it("desc 短描述优先(缺失回退完整描述)", () => {
const withShort: SkillMetadataItem[] = [
{
name: "agent-creation",
description: "Create agents from requirements",
shortDescription: "Create agents",
shortDescriptionZh: "创建 Agent",
version: 1,
updated: "",
},
];
expect(skillSlashItems(withShort, "zh")[0]!.desc).toBe("创建 Agent");
expect(skillSlashItems(withShort, "en")[0]!.desc).toBe("Create agents");
});
});
describe("quickInvokeText(技能库快捷调用的预填文本,zh/en 字典)", () => {
it("与空正文自动调用文本同口径:zh「使用 X 技能」/ en「use the X skill」", () => {
expect(zh.skills.quickInvokeText("agent-creation")).toBe("使用 agent-creation 技能");
expect(en.skills.quickInvokeText("agent-creation")).toBe("use the agent-creation skill");
});
});
describe("filterSkills(技能下拉的搜索过滤)", () => {
const skills: SkillMetadataItem[] = [
{
name: "agent-creation",
description: "Create agents from requirements",
version: 1,
updated: "2026-07-01",
},
{ name: "penguin-sdk", description: "Develop with the Penguin SDK", version: 2, updated: "" },
];
it("空查询(含纯空白)返回全量", () => {
expect(filterSkills(skills, "zh", "")).toEqual(skills);
expect(filterSkills(skills, "en", " ")).toEqual(skills);
});
it("按 name 子串过滤(大小写不敏感):sdk 只剩 penguin-sdk", () => {
expect(filterSkills(skills, "en", "sdk").map((s) => s.name)).toEqual(["penguin-sdk"]);
expect(filterSkills(skills, "en", "SDK").map((s) => s.name)).toEqual(["penguin-sdk"]);
});
it("按显示文案过滤:zh 命中中文短描述,en 恒用英文", () => {
const withZh = [{ ...skills[0]!, shortDescriptionZh: "把需求变成 Agent" }, skills[1]!];
expect(filterSkills(withZh, "zh", "需求").map((s) => s.name)).toEqual(["agent-creation"]);
expect(filterSkills(withZh, "en", "需求")).toEqual([]);
expect(filterSkills(skills, "en", "requirements").map((s) => s.name)).toEqual([
"agent-creation",
]);
});
it("无匹配给空数组", () => {
expect(filterSkills(skills, "zh", "nonexistent")).toEqual([]);
});
});
describe("skillsAutoMessage(空正文发送的自动调用文本,zh/en 字典)", () => {
it("zh:技能名以「、」相连,单复数同款", () => {
expect(zh.chat.skillsAutoMessage(["agent-creation"])).toBe("使用 agent-creation 技能");
expect(zh.chat.skillsAutoMessage(["a", "b"])).toBe("使用 a、b 技能");
});
it("en:单数 use the <name> skill、复数逗号相连 + skills", () => {
expect(en.chat.skillsAutoMessage(["agent-creation"])).toBe("use the agent-creation skill");
expect(en.chat.skillsAutoMessage(["a", "b"])).toBe("use the a, b skills");
});
});
+68
View File
@@ -0,0 +1,68 @@
/**
* skill-use.ts unit tests: building and reverse-parsing the `<use_skills>`
* block (global convention format) —
* round-trip consistency, an empty skill list omits the block, and only a
* block at the start of the message is recognized.
*/
import { describe, expect, it } from "vitest";
import { buildSkillsMessage, parseSkillsMessage } from "../src/features/chat/skill-use";
describe("buildSkillsMessage(<use_skills> 块 + 正文)", () => {
it("单技能:块(首行标签、次行 skills:、闭合标签)+ 空行 + 正文", () => {
expect(buildSkillsMessage(["penguin-sdk"], "帮我写个 demo")).toBe(
"<use_skills>\nskills: penguin-sdk\n</use_skills>\n\n帮我写个 demo",
);
});
it("多技能:skills: 行逗号 + 空格分隔", () => {
expect(buildSkillsMessage(["agent-creation", "penguin-cli"], "x")).toBe(
"<use_skills>\nskills: agent-creation, penguin-cli\n</use_skills>\n\nx",
);
});
it("空名单不包块:原样返回正文", () => {
expect(buildSkillsMessage([], "hello")).toBe("hello");
expect(buildSkillsMessage([], "")).toBe("");
});
it("无正文时只有块,无尾随空行", () => {
expect(buildSkillsMessage(["solo"], "")).toBe("<use_skills>\nskills: solo\n</use_skills>");
});
});
describe("parseSkillsMessage(逆向解析,驱动「使用技能」横幅)", () => {
it("与 build 往返一致(多技能、多行正文)", () => {
const text = buildSkillsMessage(["agent-creation", "penguin-cli"], "use them\nline2");
expect(parseSkillsMessage(text)).toEqual({
skills: ["agent-creation", "penguin-cli"],
rest: "use them\nline2",
});
});
it("正文含 < 字符(含尖括号标签样式的文本)不受影响", () => {
const body = "compare a < b and keep <tag> markers\n<use_skills> 字样也只是正文";
expect(parseSkillsMessage(buildSkillsMessage(["x-skill"], body))).toEqual({
skills: ["x-skill"],
rest: body,
});
});
it("只含块无正文:rest 为空串", () => {
expect(parseSkillsMessage("<use_skills>\nskills: solo\n</use_skills>")).toEqual({
skills: ["solo"],
rest: "",
});
});
it("块不在消息开头(前置正文/空白)不解析;普通消息不解析", () => {
const block = buildSkillsMessage(["a"], "b");
expect(parseSkillsMessage(`hi\n${block}`)).toBeNull();
expect(parseSkillsMessage(` ${block}`)).toBeNull();
expect(parseSkillsMessage("plain text mentioning <use_skills> only")).toBeNull();
});
it("skills: 行为空白视为非来源块", () => {
expect(parseSkillsMessage("<use_skills>\nskills: \n</use_skills>\n\nbody")).toBeNull();
expect(parseSkillsMessage("<use_skills>\nskills: , ,\n</use_skills>")).toBeNull();
});
});
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { SPRING_DEFAULT, SPRING_MOMENTUM, isSettled, stepSpring } from "../src/lib/spring";
import type { SpringConfig } from "../src/lib/spring";
/** Runs a number of frames at 16ms per frame, returning the final state and path. */
function run(from: number, target: number, config: SpringConfig, frames = 240) {
let x = from;
let v = 0;
const path: number[] = [];
for (let i = 0; i < frames; i++) {
[x, v] = stepSpring(x, v, target, config, 16);
path.push(x);
}
return { x, v, path };
}
describe("stepSpring", () => {
it("收敛到目标", () => {
const { x, v } = run(600, 0, SPRING_DEFAULT);
expect(Math.abs(x)).toBeLessThan(0.5);
expect(Math.abs(v)).toBeLessThan(5);
});
it("临界阻尼(damping=1)从静止出发不过冲", () => {
const { path } = run(600, 0, SPRING_DEFAULT);
for (const p of path) expect(p).toBeGreaterThanOrEqual(-0.5);
});
it("欠阻尼(damping=0.8)会过冲后回摆", () => {
const { path } = run(600, 0, SPRING_MOMENTUM);
expect(Math.min(...path)).toBeLessThan(-1);
});
it("初速度朝反方向时先随速度走再回归(速度交接语义)", () => {
let x = 100;
let v = 800; // moving away from target 0
[x, v] = stepSpring(x, v, 0, SPRING_MOMENTUM, 16);
expect(x).toBeGreaterThan(100);
for (let i = 0; i < 240; i++) [x, v] = stepSpring(x, v, 0, SPRING_MOMENTUM, 16);
expect(Math.abs(x)).toBeLessThan(0.5);
});
it("超大帧间隔(后台标签页恢复)不数值爆炸", () => {
let x = 600;
let v = 0;
for (let i = 0; i < 60; i++) [x, v] = stepSpring(x, v, 0, SPRING_MOMENTUM, 500);
expect(Number.isFinite(x)).toBe(true);
expect(Number.isFinite(v)).toBe(true);
expect(Math.abs(x)).toBeLessThan(1);
});
});
describe("isSettled", () => {
it("位置与速度双阈值", () => {
expect(isSettled(0.4, 4, 0)).toBe(true);
expect(isSettled(1, 0, 0)).toBe(false);
expect(isSettled(0, 10, 0)).toBe(false);
});
});
+281
View File
@@ -0,0 +1,281 @@
/**
* stream-controller.ts unit tests: buffer/replay phase machine, task_state as the
* authoritative state while streaming (history-closing decision), the generation guard
* against rebuild re-entrancy during replay, resync rebuild (clears the pending table +
* keeps localDecisions), approval re-delivery keyed by origin composite key + missing
* card backfill, and history load failure/retry.
*/
import { describe, expect, it } from "vitest";
import {
approvalDecision,
assistantText,
tokenUsage,
toolCall,
userText,
withOrigin,
} from "@prismshadow/penguin-core/omnimessage";
import type { OmniMessage, TokenCounts } from "@prismshadow/penguin-core/omnimessage";
import type { ServerEvent, SessionStatus } from "@prismshadow/penguin-server/api";
import { createStreamController } from "../src/lib/omni/stream-controller";
import type { StreamController } from "../src/lib/omni/stream-controller";
import { approvalKey, findToolCard } from "../src/lib/omni/stream-model";
import type { ToolCallItem } from "../src/lib/omni/stream-model";
/** Override a message timestamp (constructor defaults to the current time). */
function at<M extends OmniMessage>(msg: M, ts: string): M {
return { ...msg, timestamp: ts };
}
function counts(total: number): TokenCounts {
return { cache_read: 0, cache_write: 0, output: 0, total };
}
/** Flush microtasks/macrotasks: let async loads started inside rebuild finish. */
const flush = () => new Promise<void>((resolve) => setTimeout(resolve, 0));
interface Harness {
controller: StreamController;
states: SessionStatus[];
errors: Array<string | null>;
loadings: boolean[];
loadCalls: () => number;
resolveLoad: (messages: OmniMessage[]) => void;
rejectLoad: (err: Error) => void;
}
function createHarness(): Harness {
const pendingLoads: Array<{
resolve: (m: OmniMessage[]) => void;
reject: (e: unknown) => void;
}> = [];
const states: SessionStatus[] = [];
const errors: Array<string | null> = [];
const loadings: boolean[] = [];
let calls = 0;
const controller = createStreamController({
loadMessages: () =>
new Promise<OmniMessage[]>((resolve, reject) => {
calls += 1;
pendingLoads.push({ resolve, reject });
}),
onTaskState: (s) => states.push(s),
onLoading: (l) => loadings.push(l),
onError: (e) => errors.push(e),
onModelChange: () => {},
onPendingChange: () => {},
now: () => 1_000_000,
});
return {
controller,
states,
errors,
loadings,
loadCalls: () => calls,
resolveLoad: (messages) => pendingLoads.shift()!.resolve(messages),
rejectLoad: (err) => pendingLoads.shift()!.reject(err),
};
}
const HISTORY_TASK: OmniMessage[] = [
at(userText("问题"), "2026-07-05T00:00:00.000Z"),
at(assistantText("回答"), "2026-07-05T00:00:03.000Z"),
at(tokenUsage(counts(1000), counts(1000)), "2026-07-05T00:00:05.000Z"),
];
describe("流中 task_state 为权威运行状态(历史收口判定)", () => {
it("订阅快照 idle:历史收口,产出最后一个 Task 的统计行", async () => {
const h = createHarness();
const p = h.controller.load();
// Connection comes first: the snapshot arrives before history, so it's buffered.
h.controller.handleServer({ type: "task_state", state: "idle" });
h.resolveLoad(HISTORY_TASK);
await p;
expect(h.states).toContain("idle");
expect(h.controller.model.items.map((i) => i.kind)).toEqual([
"user_text",
"assistant_text",
"task_stats",
]);
});
it("订阅快照 running:不提前收口;随后的 idle 事件才收口", async () => {
const h = createHarness();
const p = h.controller.load();
h.controller.handleServer({ type: "task_state", state: "running" });
h.resolveLoad(HISTORY_TASK);
await p;
expect(h.controller.model.items.some((i) => i.kind === "task_stats")).toBe(false);
// The real flip event arrives (live phase) → closes out.
h.controller.handleServer({ type: "task_state", state: "idle" });
expect(h.controller.model.items.some((i) => i.kind === "task_stats")).toBe(true);
});
it("快照未到时不按列表快照收口;迟到的 idle 快照到达后完成同等收口", async () => {
const h = createHarness();
const p = h.controller.load();
h.resolveLoad(HISTORY_TASK);
await p;
// No in-stream state at all → doesn't close out (list snapshot isn't trusted).
expect(h.controller.model.items.some((i) => i.kind === "task_stats")).toBe(false);
h.controller.handleServer({ type: "task_state", state: "idle" });
expect(h.controller.model.items.some((i) => i.kind === "task_stats")).toBe(true);
});
it("缓冲期间的 task_state 即时上报输入区(不等历史回放)", async () => {
const h = createHarness();
void h.controller.load();
h.controller.handleServer({ type: "task_state", state: "running" });
// History hasn't returned yet, but state is already reported.
expect(h.states).toEqual(["running"]);
});
});
describe("审批补发(origin 组合键 + 缺卡补建)", () => {
it("子会话审批补发:找不到工具卡时用 toolCall 补建嵌套卡,重复补发不重复建卡", async () => {
const h = createHarness();
const p = h.controller.load();
h.resolveLoad([]);
await p;
const tc = withOrigin(
toolCall({ name: "exec_command", arguments: '{"cmd":"rm -rf x"}', toolCallId: "t1" }),
"c1",
);
const ev: ServerEvent = { type: "approval_request", toolCall: tc, origin: ["c1"] };
h.controller.handleServer(ev);
// The pending table is keyed by origin composite key.
expect(h.controller.pendingApprovals.has(approvalKey(["c1"], "t1"))).toBe(true);
expect(h.controller.pendingApprovals.has(approvalKey(undefined, "t1"))).toBe(false);
// The nested card is backfilled (child-session messages aren't in the parent Trace;
// without this mechanism, the approval button has nowhere to render).
const card = findToolCard(h.controller.model, ["c1"], "t1");
expect(card).not.toBeNull();
expect((card as ToolCallItem).name).toBe("exec_command");
// Repeated re-delivery (reconnect) doesn't create a duplicate card.
h.controller.handleServer(ev);
const sub = h.controller.model.subagents.get("c1")!;
expect(sub.items.filter((i) => i.kind === "tool_call")).toHaveLength(1);
});
it("主会话审批补发:历史已有工具卡则不重复建卡", async () => {
const h = createHarness();
const p = h.controller.load();
const tc = toolCall({ name: "write_file", arguments: "{}", toolCallId: "t2" });
h.resolveLoad([at(tc, "2026-07-05T00:00:00.000Z")]);
await p;
h.controller.handleServer({ type: "approval_request", toolCall: tc });
expect(h.controller.model.items.filter((i) => i.kind === "tool_call")).toHaveLength(1);
expect(h.controller.pendingApprovals.has(approvalKey(undefined, "t2"))).toBe(true);
});
it("approval_decision 事件按 origin 组合键移除对应未决项", async () => {
const h = createHarness();
const p = h.controller.load();
h.resolveLoad([]);
await p;
const tc = withOrigin(toolCall({ name: "x", arguments: "{}", toolCallId: "t1" }), "c1");
h.controller.handleServer({ type: "approval_request", toolCall: tc, origin: ["c1"] });
expect(h.controller.pendingApprovals.size).toBe(1);
h.controller.handleOmni(withOrigin(approvalDecision("allow", "t1"), "c1"));
expect(h.controller.pendingApprovals.size).toBe(0);
});
});
describe("resync 重建", () => {
it("重建清空未决审批表;服务端随后补发的仍未决请求天然重建(#28)", async () => {
const h = createHarness();
const p = h.controller.load();
h.resolveLoad([]);
await p;
const tc = toolCall({ name: "x", arguments: "{}", toolCallId: "t1" });
h.controller.handleServer({ type: "approval_request", toolCall: tc });
expect(h.controller.pendingApprovals.size).toBe(1);
h.controller.handleServer({ type: "resync_required" });
// Approvals already decided during the disconnect leave no residual button.
expect(h.controller.pendingApprovals.size).toBe(0);
// The server re-delivers the still-pending request on the same connection
// (buffered during rebuild, rebuilt after replay).
h.controller.handleServer({ type: "approval_request", toolCall: tc });
h.resolveLoad([at(tc, "2026-07-05T00:00:00.000Z")]);
await flush();
expect(h.controller.pendingApprovals.size).toBe(1);
});
it("重建保留 localDecisions:本端点过的审批重放后仍标「人工」(#22)", async () => {
const h = createHarness();
const p = h.controller.load();
const tc = at(
toolCall({ name: "x", arguments: "{}", toolCallId: "t1" }),
"2026-07-05T00:00:00.000Z",
);
h.resolveLoad([tc]);
await p;
h.controller.markLocalDecision("t1");
h.controller.handleServer({ type: "resync_required" });
h.resolveLoad([tc, at(approvalDecision("allow", "t1"), "2026-07-05T00:00:01.000Z")]);
await flush();
const card = h.controller.model.items.find((i) => i.kind === "tool_call") as ToolCallItem;
expect(card.decision).toBe("allow");
expect(card.decisionSource).toBe("manual");
});
it("回放中触发 resync:本轮作废、剩余缓冲转入新一轮,不乱序不重复(#21/#26)", async () => {
const h = createHarness();
const p = h.controller.load();
// Buffer: old event A → resync_required → task_state:idle (server re-delivery order).
h.controller.handleOmni(at(assistantText("旧事件 A"), "2026-07-05T00:00:01.000Z"));
h.controller.handleServer({ type: "resync_required" });
h.controller.handleServer({ type: "task_state", state: "idle" });
// First round of history returns: replaying up to resync_required invalidates this round.
h.resolveLoad([at(userText("问题"), "2026-07-05T00:00:00.000Z")]);
await p;
expect(h.loadCalls()).toBe(2);
// The old replay must not reset phase back to live: events arriving during rebuild
// should still be buffered, not fed to the new model.
const live = at(assistantText("重建期间的输出"), "2026-07-05T00:00:02.000Z");
h.controller.handleOmni(live);
expect(h.controller.model.items).toHaveLength(0);
// Second round of history (authoritative) returns: the transferred task_state and
// buffered events replay in order.
h.resolveLoad([
at(userText("问题"), "2026-07-05T00:00:00.000Z"),
at(tokenUsage(counts(500), counts(500)), "2026-07-05T00:00:01.500Z"),
]);
await flush();
expect(h.controller.model.items.map((i) => i.kind)).toEqual([
"user_text",
"task_stats",
"assistant_text",
]);
expect(h.controller.model.items.filter((i) => i.kind === "user_text")).toHaveLength(1);
});
});
describe("历史加载失败与重试(#6)", () => {
it("失败暴露错误并停止加载;重试保留缓冲(快照与初始事件不丢)", async () => {
const h = createHarness();
const p = h.controller.load();
h.controller.handleServer({ type: "task_state", state: "idle" });
h.rejectLoad(new Error("网络错误"));
await p;
expect(h.errors[h.errors.length - 1]).toBe("网络错误");
expect(h.loadings[h.loadings.length - 1]).toBe(false);
const retryP = h.controller.retry();
h.resolveLoad(HISTORY_TASK);
await retryP;
expect(h.errors[h.errors.length - 1]).toBeNull();
// The idle snapshot in the buffer isn't lost: the history-closing stats row is produced.
expect(h.controller.model.items.some((i) => i.kind === "task_stats")).toBe(true);
});
it("未失败时 retry 是 no-op(不会重复回放历史)", async () => {
const h = createHarness();
const p = h.controller.load();
h.resolveLoad(HISTORY_TASK);
await p;
await h.controller.retry();
expect(h.loadCalls()).toBe(1);
});
});
+78
View File
@@ -0,0 +1,78 @@
/**
* Unit tests for stream-follow.ts (issue #75): in a short scroll area (scrollable
* slack < 80px), scrolling up immediately exits auto-stick-to-bottom; staying at
* a historical position, streaming increments don't override that intent; the
* user can scroll back down to resume; content-shrink clamping isn't
* misread as an upward scroll.
*/
import { describe, expect, it } from "vitest";
import { createStreamFollow } from "../src/features/chat/stream-follow";
/** Short scroll area: content 500px, viewport 460px, scrollable slack only 40px (always under the 80px threshold). */
const SHORT = { scrollHeight: 500, clientHeight: 460 };
describe("createStreamFollow", () => {
it("短滚动区:wheel 上滚立即退出跟随,即使已在顶部(位置不再变化)", () => {
const f = createStreamFollow();
expect(f.stick).toBe(true);
f.scrolled({ ...SHORT, scrollTop: 40 }); // scroll event from the program sticking to bottom: still following.
expect(f.stick).toBe(true);
f.wheel(-3);
expect(f.stick).toBe(false);
});
it("短滚动区:滚动条/键盘上移(scrollTop 回退)同样退出", () => {
const f = createStreamFollow();
f.scrolled({ ...SHORT, scrollTop: 40 });
f.scrolled({ ...SHORT, scrollTop: 20 }); // moved up, 20px from bottom (> the 1px clamp margin).
expect(f.stick).toBe(false);
});
it("触摸下拉(手指向下移动)退出;上推不退出", () => {
const f = createStreamFollow();
f.touchStart(100);
f.touchMove(90); // finger pushes up = content scrolls down, stays following.
expect(f.stick).toBe(true);
f.touchMove(120); // finger pulls down = content scrolls up, exits.
expect(f.stick).toBe(false);
f.touchEnd();
});
it("停留历史位置时,流式增量(scrollTop 不动、内容变高)不改变意图", () => {
const f = createStreamFollow();
f.scrolled({ ...SHORT, scrollTop: 40 });
f.wheel(-3);
f.scrolled({ ...SHORT, scrollTop: 0 }); // scrolled up to the top.
expect(f.stick).toBe(false);
// Content keeps growing while the user's position stays put: as long as scrollTop
// doesn't change, following isn't mistakenly resumed.
f.scrolled({ scrollHeight: 900, clientHeight: 460, scrollTop: 0 });
expect(f.stick).toBe(false);
});
it("用户主动滚回底部附近(80px 内)恢复跟随", () => {
const f = createStreamFollow();
f.scrolled({ scrollHeight: 2000, clientHeight: 460, scrollTop: 1540 });
f.scrolled({ scrollHeight: 2000, clientHeight: 460, scrollTop: 800 }); // dragged up, exits.
expect(f.stick).toBe(false);
f.scrolled({ scrollHeight: 2000, clientHeight: 460, scrollTop: 1470 }); // back to 70px from bottom.
expect(f.stick).toBe(true);
});
it("内容收缩把 scrollTop 向下钳位(仍贴底)不算上滑", () => {
const f = createStreamFollow();
f.scrolled({ scrollHeight: 2000, clientHeight: 460, scrollTop: 1540 }); // stuck to bottom, following.
// Group collapse shrinks content height; the browser clamps scrollTop to the new
// bottom: it moved up, but it's still 0px from the bottom.
f.scrolled({ scrollHeight: 1500, clientHeight: 460, scrollTop: 1040 });
expect(f.stick).toBe(true);
});
it("首个滚动事件即处于历史位置(离底 ≥ 80px):按位置初始化为不跟随", () => {
const f = createStreamFollow();
// The first event has no direction to judge from (e.g. a restored scroll position),
// and is 540px from the bottom: it shouldn't wait for a subsequent scroll-up to exit.
f.scrolled({ scrollHeight: 2000, clientHeight: 460, scrollTop: 1000 });
expect(f.stick).toBe(false);
});
});
File diff suppressed because it is too large Load Diff
+227
View File
@@ -0,0 +1,227 @@
/**
* Unit tests for task-stats.ts: the stats-row convention matches the CLI's "统计信息"
* output item for item.
*/
import { describe, expect, it } from "vitest";
import type { TokenUsagePayload } from "@prismshadow/penguin-core/omnimessage";
import {
addLlmDuration,
beginCompaction,
commitPendingCompaction,
createTaskStatsTracker,
endCompaction,
endTask,
formatTaskStats,
resetTaskCounters,
trackMainUsage,
trackSubagentUsage,
} from "../src/lib/omni/task-stats";
function usage(requestTotal: number, sessionTotal: number): TokenUsagePayload {
return {
type: "token_usage",
session: { cache_read: 0, cache_write: 0, output: 0, total: sessionTotal },
request: { cache_read: 0, cache_write: 0, output: 0, total: requestTotal },
};
}
/** Three-bucket request (session uses its total): builds a token_usage with specific cache/output figures. */
function req(cr: number, cw: number, o: number): TokenUsagePayload {
const total = cr + cw + o;
return {
type: "token_usage",
session: { cache_read: 0, cache_write: 0, output: 0, total },
request: { cache_read: cr, cache_write: cw, output: o, total },
};
}
describe("TaskStatsTracker", () => {
it("无 token_usage 的 Task 不产出统计行,但用时仍累计", () => {
const t = createTaskStatsTracker();
expect(endTask(t, 500)).toBeNull();
expect(t.sessionElapsedMs).toBe(500);
});
it("上下文/Token/用时三项均为累计值 + 增量", () => {
const t = createTaskStatsTracker();
trackMainUsage(t, usage(1000, 1000));
const s1 = endTask(t, 2300);
expect(s1).toEqual({
context: 1000,
contextDelta: 1000,
tokens: 1000,
tokensDelta: 1000,
elapsedMs: 2300,
elapsedDeltaMs: 2300,
tokensByBucket: { cacheRead: 0, cacheWrite: 0, output: 0 },
outputTps: null,
});
// Second Task: context can drop (may be negative, not clamped to non-negative).
trackMainUsage(t, usage(800, 1800));
const s2 = endTask(t, 1000);
expect(s2).toEqual({
context: 800,
contextDelta: -200,
tokens: 1800,
tokensDelta: 800,
elapsedMs: 3300,
elapsedDeltaMs: 1000,
tokensByBucket: { cacheRead: 0, cacheWrite: 0, output: 0 },
outputTps: null,
});
});
it("分桶用量按本 Task 累加(父 + 子),供成本折算;跨 Task 重置", () => {
const t = createTaskStatsTracker();
const buckets = (cr: number, cw: number, o: number, total: number): TokenUsagePayload => ({
type: "token_usage",
session: { cache_read: 0, cache_write: 0, output: 0, total },
request: { cache_read: cr, cache_write: cw, output: o, total },
});
trackMainUsage(t, buckets(100, 10, 5, 200));
trackSubagentUsage(t, buckets(50, 0, 3, 60));
const s1 = endTask(t, 100);
expect(s1?.tokensByBucket).toEqual({ cacheRead: 150, cacheWrite: 10, output: 8 });
// Next Task accumulates from zero.
trackMainUsage(t, buckets(20, 2, 1, 40));
const s2 = endTask(t, 100);
expect(s2?.tokensByBucket).toEqual({ cacheRead: 20, cacheWrite: 2, output: 1 });
});
it("输出 TPS = 本 Task 主会话输出 ÷ LLM 秒数", () => {
const t = createTaskStatsTracker();
// No LLM timing (no request pairing) -> TPS is null, avoiding a divide-by-zero.
trackMainUsage(t, req(300, 100, 200));
expect(endTask(t, 100)?.outputTps).toBeNull();
// Output 900 tokens / 3s LLM time = 300 tok/s.
trackMainUsage(t, req(0, 0, 900));
addLlmDuration(t, 3000);
expect(endTask(t, 100)?.outputTps).toBe(300);
// Subagent output doesn't count toward the main session's TPS (matches the Trace page's
// per-round convention): 600 / 2s = 300, excluding the subagent's 400.
trackMainUsage(t, req(0, 0, 600));
trackSubagentUsage(t, req(0, 0, 400));
addLlmDuration(t, 2000);
expect(endTask(t, 100)?.outputTps).toBe(300);
});
it("子会话用量计入 Token 累计与增量,不影响上下文", () => {
const t = createTaskStatsTracker();
trackMainUsage(t, usage(1000, 1000));
trackSubagentUsage(t, usage(400, 400));
const s = endTask(t, 100);
expect(s?.context).toBe(1000);
expect(s?.tokens).toBe(1400); // parent session.total + subagent cumulative
expect(s?.tokensDelta).toBe(1400); // sum of this Task's parent + subagent request totals
expect(t.subagentTotal).toBe(400); // persists across Tasks
});
it("轮**结束后**的压缩(挂起后未落实):Token / 成本 / 上下文都不沾,只累进会话总数", () => {
const t = createTaskStatsTracker();
trackMainUsage(t, usage(1000, 1000));
beginCompaction(t);
trackMainUsage(t, usage(300, 1300)); // compaction request: request 300 -> stays pending first
expect(t.contextNow).toBe(1000); // compaction doesn't update the context figure
expect(t.sessionTotal).toBe(1300); // but the session total still tracks the provider (includes compaction; nothing leaks at the session level)
endCompaction(t, "completed");
// No commitPendingCompaction follows (no normal request_end for this round before closing) ->
// compaction after the round ends is discarded when the round closes.
expect(endTask(t, 100)?.tokensDelta).toBe(1000);
});
it("轮**途中**的压缩(commitPendingCompaction 落实):Token / 成本计入本轮,但不进上下文 / TPS", () => {
const t = createTaskStatsTracker();
trackMainUsage(t, req(200, 0, 100)); // own1: request 300 (cache 200 + output 100)
beginCompaction(t);
trackMainUsage(t, req(0, 500, 40)); // compaction request: request 540 (cache write 500 + output 40) -> pending
expect(t.contextNow).toBe(300); // compaction doesn't update the context figure
endCompaction(t, "completed");
// This round still has a normal Request after compaction -> commit the pending compaction
// usage at that request's request_end.
commitPendingCompaction(t);
trackMainUsage(t, req(0, 0, 200)); // own2: request 200 (output 200)
addLlmDuration(t, 4000); // LLM wall clock for the two normal requests (compaction request excluded)
const s = endTask(t, 100);
expect(s?.tokensDelta).toBe(1040); // 300 + 540 (compaction) + 200
expect(s?.tokensByBucket).toEqual({ cacheRead: 200, cacheWrite: 500, output: 340 }); // includes compaction
// The TPS numerator only counts normal-request output (100 + 200 = 300, compaction's 40 excluded): 300 / 4s = 75 tok/s.
expect(s?.outputTps).toBe(75);
});
it("压缩成功后上下文占用标记过期(圆环画空,不停留在压缩前的旧值);压缩被放弃则不置位", () => {
// The ring reads usage **live**: once compaction succeeds, the old value no longer holds,
// and the new usage isn't measurable until the next normal Request's token_usage arrives.
// Without this flag, right after a user runs /compact the ring would still show "nearly
// full" — but the whole point of manual compaction is to see the space freed up. (The CLI
// only prints the stats row at the end of each round, and the next line is guaranteed to
// already have new token_usage, so this issue never surfaced there.)
const t = createTaskStatsTracker();
trackMainUsage(t, usage(120_000, 120_000));
beginCompaction(t);
trackMainUsage(t, usage(2_000, 122_000)); // the compaction request's own usage
endCompaction(t, "completed");
expect(t.contextStale).toBe(true);
// Only sets the flag, doesn't touch contextNow: it also serves as TaskStats' per-round
// history (context and the negative delta from "compaction dropping usage").
expect(t.contextNow).toBe(120_000);
// The next normal Request measures the new usage -> the flag clears, the ring shows the real number.
trackMainUsage(t, usage(15_000, 137_000));
expect(t.contextStale).toBe(false);
expect(t.contextNow).toBe(15_000);
// Compaction abandoned: the original context still holds (per core's CompactionEndPayload.status comment), so the flag isn't set.
const t2 = createTaskStatsTracker();
trackMainUsage(t2, usage(120_000, 120_000));
beginCompaction(t2);
endCompaction(t2, "aborted");
expect(t2.contextStale).toBe(false);
expect(t2.contextNow).toBe(120_000);
});
it("resetTaskCounters 防止 Task 边界外用量误记入下一 Task", () => {
const t = createTaskStatsTracker();
// Manual compaction (outside the Task boundary) consumes usage.
beginCompaction(t);
trackMainUsage(t, usage(300, 300));
endCompaction(t);
// Reset when the new Task starts.
resetTaskCounters(t);
trackMainUsage(t, usage(500, 800));
const s = endTask(t, 100);
expect(s?.tokensDelta).toBe(500); // excludes the compaction's 300
expect(s?.tokens).toBe(800);
});
});
describe("formatTaskStats", () => {
it("口径为本轮用量「输入(已缓存)· 输出 · 输出 TPS」,取本 Task 三桶用量 + 本 Task TPS", () => {
expect(
formatTaskStats({
context: 40000, // context usage isn't in the stats row (shown by the input-box ring instead)
contextDelta: 1000,
tokens: 60000,
tokensDelta: 1200,
elapsedMs: 5100,
elapsedDeltaMs: 2300,
tokensByBucket: { cacheRead: 3000, cacheWrite: 1000, output: 1200 },
outputTps: 42.5,
}),
).toBe("[统计信息] 输入 tokens 4k(已缓存 3k) · 输出 tokens 1.2k · 42.5 tok/s");
});
it("无 LLM 计时时 TPS 显示 —", () => {
expect(
formatTaskStats({
context: 500,
contextDelta: 0,
tokens: 500,
tokensDelta: 500,
elapsedMs: 900,
elapsedDeltaMs: 900,
tokensByBucket: { cacheRead: 500, cacheWrite: 0, output: 0 },
outputTps: null,
}),
).toBe("[统计信息] 输入 tokens 500(已缓存 500) · 输出 tokens 0 · —");
});
});
+252
View File
@@ -0,0 +1,252 @@
/**
* Cost Center chart pure-function unit tests (chart-geom.ts): coordinate mapping, SVG path
* assembly, Token bar chart horizontal layout (fixed 25px bar width / spacing ≥ bar width /
* whether it scrolls horizontally), stacked-bar segment geometry and per-segment hit bands,
* pie slice arcs, success rate. Component interaction isn't covered here (vitest runs in a
* node environment, no DOM).
*
* Canvas width is "measured container pixels" (1 canvas unit = 1 CSS pixel), so each case
* passes an explicit width; 640 was the original fixed canvas width, and reusing it as the
* sample width also proves the coordinate / path math is unchanged (zero regression for the
* cost line chart).
*/
import { describe, expect, it } from "vitest";
import {
makeGeom,
linePath,
areaPath,
sparseLabelIdx,
autoLabelIdx,
successRate,
tokenBarLayout,
barSegments,
pieSlices,
BAR_W,
MIN_HIT_H,
PAD_L,
PAD_R,
} from "../src/features/usage/chart-geom";
describe("makeGeom", () => {
it("x 取每格中点、y 以 max 为满格自上而下", () => {
const g = makeGeom(2, 100, 640);
// innerW=586, step=293; x=PAD_L(46)+step*i+step/2
expect(g.w).toBe(640);
expect(g.step).toBe(293);
expect(g.x(0)).toBe(192.5);
expect(g.x(1)).toBe(485.5);
// innerH=168; y(0)=PAD_T(10)+168, y(max)=PAD_T
expect(g.y(0)).toBe(178);
expect(g.y(100)).toBe(10);
expect(g.y(50)).toBe(94);
});
it("画布宽由调用方给(1 单位 = 1 像素):内宽随之伸缩", () => {
const g = makeGeom(30, 100, 1554);
expect(g.innerW).toBe(1500);
expect(g.step).toBe(50);
expect(g.x(0)).toBe(71); // 46 + 0 + 25 (unchanged, kept for reference)
});
it("n=0 时 step 回退为整个内宽(不除零)", () => {
expect(makeGeom(0, 1, 640).step).toBe(586);
});
});
describe("linePath / areaPath", () => {
const g = makeGeom(2, 100, 640);
it("折线:M 起点 + 逐点 L(与旧成本折线一字不差)", () => {
expect(linePath(g, [100, 0])).toBe("M192.5,10 L485.5,178");
});
it("面积:折线末端落到基线、回到起点闭合", () => {
expect(areaPath(g, [100, 0])).toBe("M192.5,10 L485.5,178 L485.5,178 L192.5,178 Z");
});
it("空序列返回空串", () => {
expect(areaPath(g, [])).toBe("");
});
});
describe("tokenBarLayout", () => {
/** Bar width is real pixels, so inner width = container width - left/right padding: a 990 container has inner width 936. */
const inner = (containerW: number) => containerW - PAD_L - PAD_R;
it("柱宽固定 25px:30 天在半格(495px)里塞不下 → 撑出超宽画布,横向滚动", () => {
const l = tokenBarLayout(495, 30);
expect(l.barW).toBe(25);
expect(l.barW).toBe(BAR_W);
// 30 slots x (25 bar + 25 gap) = 1500 > inner width 441 -> canvas = 46 + 1500 + 8
expect(l.chartW).toBe(1554);
expect(l.chartW).toBeGreaterThan(495);
expect(l.scroll).toBe(true);
});
it("塞不下时柱间距 = 柱宽:每格 = 2×柱宽,柱居格中 → 柱间空白正好一个柱宽", () => {
const l = tokenBarLayout(495, 30);
const g = makeGeom(30, 1, l.chartW);
expect(g.step).toBe(2 * l.barW);
// Two adjacent bars: left bar's right edge = x(0)+barW/2, right bar's left edge = x(1)-barW/2
const gap = g.x(1) - l.barW / 2 - (g.x(0) + l.barW / 2);
expect(gap).toBeCloseTo(l.barW);
// Half a gap is left on the first bar's left / last bar's right, so bars don't touch the axis or overflow the canvas
expect(g.x(0) - l.barW / 2).toBeCloseTo(PAD_L + l.barW / 2);
expect(g.x(29) + l.barW / 2).toBeCloseTo(l.chartW - PAD_R - l.barW / 2);
});
it("点数少:柱子**不撑宽**(25px 是固定值不是下限),富余空间全部让给柱间距", () => {
const l = tokenBarLayout(990, 7);
expect(l.barW).toBe(BAR_W); // the old implementation would stretch it to 936/14 = 66.86px
expect(l.chartW).toBe(990); // the canvas still fills the container: the slack goes into the bar gaps, not the right edge
expect(l.scroll).toBe(false);
// Each slot = inner width / 7 = 133.7px: a 25px bar centered, with 108.7px of gap (>= bar width)
const g = makeGeom(7, 1, l.chartW);
expect(g.step).toBeCloseTo(inner(990) / 7);
const gap = g.x(1) - l.barW / 2 - (g.x(0) + l.barW / 2);
expect(gap).toBeCloseTo(inner(990) / 7 - BAR_W);
expect(gap).toBeGreaterThan(BAR_W);
});
it("临界点:每格正好 50px 时铺满不滚动,再多一天就超宽 + 滚动(柱宽始终 25px)", () => {
const exact = inner(990) / 50; // 18.72 days
const fits = Math.floor(exact); // 18 days: inner width 936 >= 18x50 = 900
expect(tokenBarLayout(990, fits)).toMatchObject({ barW: BAR_W, chartW: 990, scroll: false });
expect(tokenBarLayout(990, fits + 1)).toMatchObject({ barW: BAR_W, scroll: true });
expect(tokenBarLayout(990, fits + 1).chartW).toBe(PAD_L + 2 * BAR_W * 19 + PAD_R);
});
it("整行容器 + 少量日点:柱宽仍是 25px(旧实现在这里胖到 ~180px)", () => {
const l = tokenBarLayout(990, 3);
expect(l.barW).toBe(25);
expect(l.chartW).toBe(990);
expect(l.scroll).toBe(false);
});
});
describe("autoLabelIdx", () => {
it("每格够宽就逐日标(Token 柱图每格 ≥ 50px)", () => {
expect(autoLabelIdx(30, 50)).toEqual(Array.from({ length: 30 }, (_, i) => i));
expect(autoLabelIdx(7, 133)).toEqual([0, 1, 2, 3, 4, 5, 6]);
});
it("格窄时按需隔格标,不糊在一起", () => {
expect(autoLabelIdx(30, 20)).toEqual([0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28]);
expect(autoLabelIdx(30, 10)).toEqual([0, 4, 8, 12, 16, 20, 24, 28]);
expect(autoLabelIdx(0, 50)).toEqual([]);
});
});
describe("barSegments", () => {
// max=100, innerH=168: 1 unit of value = 1.68 canvas units; baseline y(0)=178.
const g = makeGeom(30, 100, 640);
it("自底向上 output → cacheWrite → cacheRead,三段严丝合缝、总高 = 当日总量", () => {
const segs = barSegments(g, { cacheRead: 50, cacheWrite: 30, output: 20 });
expect(segs.map((s) => s.key)).toEqual(["output", "cacheWrite", "cacheRead"]);
// output 20 sits on the baseline: 178 - 20*1.68 = 144.4
expect(segs[0]!.y).toBeCloseTo(144.4);
expect(segs[0]!.h).toBeCloseTo(33.6);
// cacheWrite 30 stacks on top of output; its top edge = cumulative 50 -> y(50)=94
expect(segs[1]!.y).toBeCloseTo(94);
expect(segs[1]!.h).toBeCloseTo(50.4);
// cacheRead 50 caps the stack; its top edge = cumulative 100 -> y(100)=10
expect(segs[2]!.y).toBeCloseTo(10);
expect(segs[2]!.h).toBeCloseTo(84);
// Segments connect seamlessly: the previous segment's top edge = the next segment's bottom edge
expect(segs[0]!.y).toBeCloseTo(segs[1]!.y + segs[1]!.h);
expect(segs[1]!.y).toBeCloseTo(segs[2]!.y + segs[2]!.h);
});
it("零值桶不出段(不画、也不该被 hover 到);全零当日无段", () => {
expect(barSegments(g, { cacheRead: 10, cacheWrite: 0, output: 5 }).map((s) => s.key)).toEqual([
"output",
"cacheRead",
]);
expect(barSegments(g, { cacheRead: 0, cacheWrite: 0, output: 0 })).toEqual([]);
// With only one bucket left: the lone segment's hit band = the whole bar
const one = barSegments(g, { cacheRead: 4, cacheWrite: 0, output: 0 });
expect(one.map((s) => s.key)).toEqual(["cacheRead"]);
expect(one[0]!.hitH).toBeCloseTo(g.y(0) - g.y(4));
});
it("命中带铺满整根柱、互不重叠(自底向上首尾相接)", () => {
const segs = barSegments(g, { cacheRead: 50, cacheWrite: 30, output: 20 });
const base = g.y(0);
const top = g.y(100);
expect(segs[0]!.hitY + segs[0]!.hitH).toBeCloseTo(base); // the bottommost segment sits on the baseline
expect(segs[0]!.hitY).toBeCloseTo(segs[1]!.hitY + segs[1]!.hitH);
expect(segs[1]!.hitY).toBeCloseTo(segs[2]!.hitY + segs[2]!.hitH);
expect(segs[2]!.hitY).toBeCloseTo(top); // the topmost segment caps at the bar's top
expect(segs.reduce((s, x) => s + x.hitH, 0)).toBeCloseTo(base - top);
});
it("亚像素的小段:命中带抬到下限(否则 output 常不足 1%、根本 hover 不到),大段等比让出空间", () => {
// Realistic shape: cacheRead is 99%, output only 0.5% -> visual height under 1 unit.
const segs = barSegments(g, { cacheRead: 99, cacheWrite: 0.5, output: 0.5 });
const out = segs.find((s) => s.key === "output")!;
const read = segs.find((s) => s.key === "cacheRead")!;
expect(out.h).toBeLessThan(1); // the visual rectangle still strictly follows the value (no inflated bar height)
expect(out.hitH).toBe(MIN_HIT_H); // the hit band is raised to the floor
expect(read.hitH).toBeCloseTo(g.y(0) - g.y(100) - 2 * MIN_HIT_H); // the large segment yields space
expect(segs.reduce((s, x) => s + x.hitH, 0)).toBeCloseTo(g.y(0) - g.y(100));
});
it("整根柱都矮于 k*minHit 时命中带等分(谁也挤不走谁)", () => {
const segs = barSegments(g, { cacheRead: 2, cacheWrite: 2, output: 2 });
const total = g.y(0) - g.y(6); // 6 * 1.68 = 10.08 < 3 * 8
for (const s of segs) expect(s.hitH).toBeCloseTo(total / 3);
});
});
describe("pieSlices", () => {
it("按值占比自 12 点顺时针排布:半圆的 arc 起于顶、止于底", () => {
const [a, b] = pieSlices([1, 1], 50, 50, 40);
expect(a!.frac).toBe(0.5);
expect(a!.start).toBe(0);
expect(a!.end).toBeCloseTo(Math.PI);
// First slice: center -> 12 o'clock -> clockwise (sweep=1) to 6 o'clock; exactly a
// half-circle, so large-arc=0
expect(a!.path).toBe("M50,50 L50,10 A40,40 0 0 1 50,90 Z");
// The second slice continues to finish the bottom half
expect(b!.path).toBe("M50,50 L50,90 A40,40 0 0 1 50,10 Z");
});
it("跨过半圆的扇区置 large-arc=1", () => {
const [big] = pieSlices([3, 1], 50, 50, 40);
expect(big!.frac).toBe(0.75);
expect(big!.path).toBe("M50,50 L50,10 A40,40 0 1 1 10,50 Z");
});
it("单个类别独占 100% 时退化为整圆(两段半圆 arc,A 指令首尾重合画不出东西)", () => {
const [only] = pieSlices([7], 50, 50, 40);
expect(only!.frac).toBe(1);
expect(only!.path).toBe("M50,10 A40,40 0 1 1 50,90 A40,40 0 1 1 50,10 Z");
});
it("非正值不出扇区(保留原下标供调用方回查名称/配色);总量 ≤ 0 时为空", () => {
const slices = pieSlices([5, 0, 5], 50, 50, 40);
expect(slices.map((s) => s.index)).toEqual([0, 2]);
expect(slices.map((s) => s.frac)).toEqual([0.5, 0.5]);
expect(pieSlices([], 50, 50, 40)).toEqual([]);
expect(pieSlices([0, 0], 50, 50, 40)).toEqual([]);
});
});
describe("sparseLabelIdx", () => {
it("首/中/尾稀疏标注", () => {
expect(sparseLabelIdx(0)).toEqual([]);
expect(sparseLabelIdx(1)).toEqual([0]);
expect(sparseLabelIdx(2)).toEqual([0, 1]);
expect(sparseLabelIdx(5)).toEqual([0, 2, 4]);
expect(sparseLabelIdx(30)).toEqual([0, 14, 29]);
});
});
describe("successRate", () => {
it("completed/total,无请求视为 1", () => {
expect(successRate(99, 100)).toBeCloseTo(0.99);
expect(successRate(5, 10)).toBe(0.5);
expect(successRate(0, 0)).toBe(1);
});
});
+96
View File
@@ -0,0 +1,96 @@
/**
* work-summary.ts unit tests (issue #76): group header duration is computed as the union
* of time intervals — overlapping time from parallel tools counts once; sequential calls
* accumulate as usual (gaps don't count); approval waits are excluded per the durationMs
* convention; the argument-generation segment and execution segment are separate intervals
* (generation is not shifted into the execution period); items missing a start point fall
* back to direct accumulation.
*/
import { describe, expect, it } from "vitest";
import type { ChatItem } from "../src/lib/omni/stream-model";
import { summarizeWork } from "../src/features/chat/work-summary";
let nextId = 1;
const thinking = (startedAtMs?: number, durationMs?: number): ChatItem => ({
kind: "thinking",
id: nextId++,
thinking: "",
streaming: false,
...(startedAtMs !== undefined ? { startedAtMs } : {}),
...(durationMs !== undefined ? { durationMs } : {}),
});
const tool = (
o: { argStart?: number; start?: number; approvalAt?: number; durationMs?: number } = {},
): ChatItem => ({
kind: "tool_call",
id: nextId++,
toolCallId: `call_${nextId}`,
name: "run_subagent",
argumentsText: "{}",
callStreaming: false,
callComplete: true,
output: "",
outputStreaming: false,
outputComplete: true,
...(o.argStart !== undefined ? { argStartedAtMs: o.argStart } : {}),
...(o.start !== undefined ? { callStartedAtMs: o.start } : {}),
...(o.approvalAt !== undefined ? { approvalAtMs: o.approvalAt } : {}),
...(o.durationMs !== undefined ? { durationMs: o.durationMs } : {}),
});
describe("summarizeWork", () => {
it("并行工具的重叠时间只算一次(15 路并行 9 分钟不再报 99 分钟)", () => {
const nine = 9 * 60_000;
const items = Array.from({ length: 15 }, () => tool({ start: 0, durationMs: nine }));
expect(summarizeWork(items)).toEqual({ steps: 15, durationMs: nine });
});
it("部分重叠按并集合并", () => {
const items = [
tool({ start: 0, durationMs: 60_000 }),
tool({ start: 10_000, durationMs: 60_000 }),
tool({ start: 20_000, durationMs: 70_000 }),
];
expect(summarizeWork(items)).toEqual({ steps: 3, durationMs: 90_000 });
});
it("顺序调用照常累计,空档不计", () => {
const items = [
tool({ start: 0, durationMs: 10_000 }),
tool({ start: 15_000, durationMs: 10_000 }),
];
expect(summarizeWork(items)).toEqual({ steps: 2, durationMs: 20_000 });
});
it("思考与工具同轴合并;思考不计步数", () => {
const items = [thinking(0, 5_000), tool({ start: 5_000, durationMs: 10_000 })];
expect(summarizeWork(items)).toEqual({ steps: 1, durationMs: 15_000 });
});
it("审批等待不计入:区间自 approvalAtMs 起算", () => {
const items = [
tool({ start: 0, approvalAt: 100_000, durationMs: 5_000 }),
tool({ start: 102_000, durationMs: 8_000 }),
];
// [100s,105s] ∪ [102s,110s] = 10s; starting from callStartedAtMs by mistake would include the 100s approval wait.
expect(summarizeWork(items)).toEqual({ steps: 2, durationMs: 10_000 });
});
it("参数生成段与执行段分属两个区间:生成段不平移进执行期", () => {
const items = [
// A: arg generation 0-10s, approval wait 10-100s, execution 100-110s (durationMs = 10s generation + 10s execution).
tool({ argStart: 0, start: 10_000, approvalAt: 100_000, durationMs: 20_000 }),
// B: execution 110-120s. If A were computed entirely from its execution start ([100s,120s]), it would fully overlap with B, leaving only 20s.
tool({ start: 110_000, durationMs: 10_000 }),
];
// Correct union: [0,10s] ∪ [100s,120s] = 30s.
expect(summarizeWork(items)).toEqual({ steps: 2, durationMs: 30_000 });
});
it("缺起点的项退化为直接累加;无耗时的项只计步数", () => {
const items = [tool({ start: 0, durationMs: 10_000 }), tool({ durationMs: 7_000 }), tool()];
expect(summarizeWork(items)).toEqual({ steps: 3, durationMs: 17_000 });
});
});