45bfae6e94
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
897 lines
38 KiB
TypeScript
897 lines
38 KiB
TypeScript
/**
|
||
* Error-record persistence unit and integration tests: ErrorsRepo's aggregation semantics (including cross-tenant isolation
|
||
* where unattributed errors are **visible only to admins**) and its row-cap eviction
|
||
* (evicts the oldest by id, without misfiring on id gaps left by deleteByProject);
|
||
* ErrorRecorder's expected/unexpected determination (explicit kind takes priority, HTTP
|
||
* infers from HttpError), short-window deduplication (storm protection), and the
|
||
* "never throws itself" guarantee; StreamErrorWatcher picking up LLM / Environment
|
||
* errors from the message stream (attributed to **the Session that actually produced
|
||
* the error**: a child Session's failure is attributed to the child Agent / child
|
||
* Session); HTTP onError actually persisting records; cascading cleanup on Project
|
||
* deletion.
|
||
*/
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||
import type { DatabaseSync } from "node:sqlite";
|
||
import {
|
||
abortEvent,
|
||
assistantText,
|
||
partialToolCallOutput,
|
||
requestBegin,
|
||
requestEnd,
|
||
sessionMeta,
|
||
toolCall,
|
||
toolCallOutput,
|
||
withOrigin,
|
||
} from "@prismshadow/penguin-core";
|
||
import type { OmniMessage } from "@prismshadow/penguin-core";
|
||
import type { ProjectCreateResponse, UsageResponse } from "../src/api/types.js";
|
||
import { openDatabase } from "../src/db/database.js";
|
||
import { ErrorsRepo } from "../src/db/repos/errors.js";
|
||
import type { ErrorRecordInsert } from "../src/db/repos/errors.js";
|
||
import { HttpError } from "../src/http/errors.js";
|
||
import {
|
||
DEDUP_KEYS_MAX,
|
||
DEDUP_WINDOW_MS,
|
||
ErrorRecorder,
|
||
MESSAGE_MAX,
|
||
} from "../src/runtime/error-recorder.js";
|
||
import { StreamErrorWatcher } from "../src/runtime/stream-error-watcher.js";
|
||
import { apiClient, createTestApp, loginAdmin, provisionUser } from "./helpers.js";
|
||
import type { TestApp } from "./helpers.js";
|
||
|
||
function row(date: string, o: Partial<ErrorRecordInsert> = {}): ErrorRecordInsert {
|
||
return {
|
||
ts: `${date}T10:00:00.000Z`,
|
||
date,
|
||
projectId: "p1",
|
||
agentId: null,
|
||
sessionId: null,
|
||
source: "http",
|
||
kind: "unexpected",
|
||
code: "internal",
|
||
status: 500,
|
||
message: "boom",
|
||
...o,
|
||
};
|
||
}
|
||
|
||
describe("errors-repo", () => {
|
||
let db: DatabaseSync;
|
||
let repo: ErrorsRepo;
|
||
|
||
beforeEach(() => {
|
||
db = openDatabase(":memory:");
|
||
repo = new ErrorsRepo(db);
|
||
});
|
||
afterEach(() => db.close());
|
||
|
||
it("汇总:总数与其中未预期数;expected 照记不丢", () => {
|
||
repo.insert(row("2026-07-06"));
|
||
repo.insert(row("2026-07-06", { kind: "expected", code: "not_found", status: 404 }));
|
||
repo.insert(row("2026-07-06", { kind: "expected", code: "bad_request", status: 400 }));
|
||
expect(repo.summary("p1")).toEqual({ total: 3, unexpected: 1 });
|
||
});
|
||
|
||
it("无归属异常(登录失败 / 进程崩溃)只对管理员可见:普通成员只看本 Project", () => {
|
||
const global = { projectId: null, source: "process", code: "uncaught_exception" };
|
||
repo.insert(row("2026-07-06", global)); // Unattributed: another tenant's login failure / process crash
|
||
repo.insert(row("2026-07-06", global));
|
||
repo.insert(row("2026-07-06", { projectId: "p-other" })); // Another Project: invisible to everyone
|
||
repo.insert(row("2026-07-06", { kind: "expected", code: "not_found", status: 404 })); // This Project
|
||
|
||
// Regular member (default includeGlobal=false): all three queries see only the row for this Project.
|
||
expect(repo.summary("p1")).toEqual({ total: 1, unexpected: 0 });
|
||
expect(repo.topCode("p1")).toMatchObject({ code: "not_found", count: 1 });
|
||
expect(repo.recent("p1").map((r) => r.code)).toEqual(["not_found"]);
|
||
|
||
// Admin: this Project + unattributed (still can't see another Project's rows).
|
||
const admin = { includeGlobal: true };
|
||
expect(repo.summary("p1", admin)).toEqual({ total: 3, unexpected: 2 });
|
||
expect(repo.topCode("p1", admin)).toMatchObject({ code: "uncaught_exception", count: 2 });
|
||
expect(repo.recent("p1", admin).map((r) => r.code)).toEqual([
|
||
"not_found",
|
||
"uncaught_exception",
|
||
"uncaught_exception",
|
||
]);
|
||
|
||
// A member of another Project likewise only sees their own row: unattributed errors never land in any regular member's view.
|
||
expect(repo.summary("p-other")).toEqual({ total: 1, unexpected: 1 });
|
||
expect(repo.recent("p-other").map((r) => r.code)).toEqual(["internal"]);
|
||
});
|
||
|
||
it("最常见错误码:按 source+code+kind 分组取次数最高的一条", () => {
|
||
for (let i = 0; i < 3; i++) repo.insert(row("2026-07-06", { code: "internal" }));
|
||
repo.insert(row("2026-07-06", { source: "session", code: "session_run_failed" }));
|
||
repo.insert(row("2026-07-06", { kind: "expected", code: "not_found", status: 404 }));
|
||
repo.insert(row("2026-07-06", { kind: "expected", code: "not_found", status: 404 }));
|
||
|
||
expect(repo.topCode("p1")).toEqual({
|
||
source: "http",
|
||
code: "internal",
|
||
kind: "unexpected",
|
||
count: 3,
|
||
});
|
||
// No errors / no errors in range -> null (the frontend uses this to hide the metric).
|
||
expect(repo.topCode("p-empty")).toBeNull();
|
||
expect(repo.topCode("p1", { from: "2026-07-07" })).toBeNull();
|
||
});
|
||
|
||
it("日期区间与 agent 过滤(HTTP / 进程级异常无 agent_id,按 Agent 过滤时天然只剩该 Agent)", () => {
|
||
repo.insert(row("2026-07-05"));
|
||
repo.insert(row("2026-07-06", { kind: "expected" }));
|
||
repo.insert(row("2026-07-06", { agentId: "a1", source: "session" }));
|
||
|
||
expect(repo.summary("p1")).toEqual({ total: 3, unexpected: 2 });
|
||
expect(repo.summary("p1", { from: "2026-07-06" })).toEqual({ total: 2, unexpected: 1 });
|
||
expect(repo.summary("p1", { agentId: "a1" })).toEqual({ total: 1, unexpected: 1 });
|
||
expect(repo.topCode("p1", { agentId: "a1" })).toMatchObject({ source: "session", count: 1 });
|
||
expect(repo.recent("p1", { agentId: "a1" })).toHaveLength(1);
|
||
});
|
||
|
||
it("最近异常:时间倒序取前 limit 条", () => {
|
||
repo.insert(row("2026-07-05", { message: "旧" }));
|
||
repo.insert(row("2026-07-06", { message: "新" }));
|
||
const recent = repo.recent("p1", {}, 1);
|
||
expect(recent).toHaveLength(1);
|
||
expect(recent[0]!.message).toBe("新");
|
||
});
|
||
|
||
it("deleteByProject:只删该 Project 的行,无归属异常保留", () => {
|
||
repo.insert(row("2026-07-06"));
|
||
repo.insert(row("2026-07-06", { projectId: null }));
|
||
repo.deleteByProject("p1");
|
||
const rows = db.prepare("SELECT project_id FROM error_records").all();
|
||
expect(rows).toHaveLength(1);
|
||
expect(rows[0]!.project_id).toBeNull();
|
||
});
|
||
|
||
// —— Row cap (the second line of defense against error storms; the first is ErrorRecorder's short-window dedup) ——
|
||
|
||
const messages = () =>
|
||
db
|
||
.prepare("SELECT message FROM error_records ORDER BY id")
|
||
.all()
|
||
.map((r) => r.message as string);
|
||
|
||
it("容量上限:超出后按 id 淘汰最旧的行(每 pruneEvery 次插入才检查一次)", () => {
|
||
const capped = new ErrorsRepo(db, { maxRows: 5, pruneEvery: 2 });
|
||
for (let i = 0; i < 10; i++) capped.insert(row("2026-07-06", { message: `m${i}` }));
|
||
// The 5 most recent rows within the cap are kept, older ones are evicted.
|
||
expect(messages()).toEqual(["m5", "m6", "m7", "m8", "m9"]);
|
||
});
|
||
|
||
it("淘汰按行数算:deleteByProject 留下的 id 空洞不会误删有效数据(近似式会)", () => {
|
||
const capped = new ErrorsRepo(db, { maxRows: 3, pruneEvery: 1 });
|
||
capped.insert(row("2026-07-06", { message: "keep-1" })); // id 1
|
||
capped.insert(row("2026-07-06", { message: "keep-2" })); // id 2
|
||
capped.insert(row("2026-07-06", { projectId: "p-gone", message: "gone" })); // id 3
|
||
capped.deleteByProject("p-gone"); // id 3 becomes a gap: MAX(id) is now decoupled from the actual row count
|
||
|
||
// 3 rows in the table = exactly at the cap; none should be deleted. An approximation (id <= MAX(id) - 3) would wrongly delete keep-1.
|
||
capped.insert(row("2026-07-06", { message: "keep-3" })); // id 4
|
||
expect(messages()).toEqual(["keep-1", "keep-2", "keep-3"]);
|
||
|
||
// Once over the cap, the oldest row is evicted as usual (gaps don't affect the "oldest" determination).
|
||
capped.insert(row("2026-07-06", { message: "keep-4" })); // id 5
|
||
expect(messages()).toEqual(["keep-2", "keep-3", "keep-4"]);
|
||
});
|
||
});
|
||
|
||
describe("error-recorder", () => {
|
||
let db: DatabaseSync;
|
||
let repo: ErrorsRepo;
|
||
const now = () => new Date("2026-07-06T10:00:00");
|
||
|
||
beforeEach(() => {
|
||
db = openDatabase(":memory:");
|
||
repo = new ErrorsRepo(db);
|
||
});
|
||
afterEach(() => db.close());
|
||
|
||
it("HttpError → expected(保留 code 与状态码)", () => {
|
||
new ErrorRecorder(repo, now).record({
|
||
source: "http",
|
||
err: new HttpError(404, "session_not_found", "Session 不存在或无权访问。"),
|
||
ctx: { projectId: "p1" },
|
||
});
|
||
const r = db.prepare("SELECT * FROM error_records").get()!;
|
||
expect(r.kind).toBe("expected");
|
||
expect(r.code).toBe("session_not_found");
|
||
expect(r.status).toBe(404);
|
||
expect(r.project_id).toBe("p1");
|
||
expect(r.date).toBe("2026-07-06");
|
||
});
|
||
|
||
it("非 HttpError → unexpected;HTTP 来源收敛 500,非 HTTP 来源 status 为 NULL", () => {
|
||
const rec = new ErrorRecorder(repo, now);
|
||
rec.record({ source: "http", err: new Error("boom") });
|
||
rec.record({
|
||
source: "session",
|
||
err: new Error("drive 挂了"),
|
||
ctx: { projectId: "p1", agentId: "a1", sessionId: "s1" },
|
||
code: "session_run_failed",
|
||
});
|
||
const rows = db.prepare("SELECT * FROM error_records ORDER BY id").all();
|
||
expect(rows[0]!.kind).toBe("unexpected");
|
||
expect(rows[0]!.code).toBe("internal"); // Matches the same code convention as handleError's external-facing code
|
||
expect(rows[0]!.status).toBe(500);
|
||
expect(rows[0]!.project_id).toBeNull();
|
||
expect(rows[1]!.code).toBe("session_run_failed");
|
||
expect(rows[1]!.status).toBeNull();
|
||
expect(rows[1]!.agent_id).toBe("a1");
|
||
expect(rows[1]!.session_id).toBe("s1");
|
||
});
|
||
|
||
it("非 Error 抛出物与超长 message:String 化并截断到上限", () => {
|
||
const rec = new ErrorRecorder(repo, now);
|
||
rec.record({ source: "process", err: "字符串异常", code: "unhandled_rejection" });
|
||
rec.record({ source: "usage", err: new Error("x".repeat(MESSAGE_MAX + 100)) });
|
||
const rows = db.prepare("SELECT message FROM error_records ORDER BY id").all();
|
||
expect(rows[0]!.message).toBe("字符串异常");
|
||
expect((rows[1]!.message as string).length).toBe(MESSAGE_MAX);
|
||
});
|
||
|
||
it("记录器自身出错绝不外抛(否则挂在 onError 上会无限递归)", () => {
|
||
const broken = {
|
||
insert() {
|
||
throw new Error("DB 已关闭");
|
||
},
|
||
} as unknown as ErrorsRepo;
|
||
expect(() =>
|
||
new ErrorRecorder(broken).record({ source: "http", err: new Error("x") }),
|
||
).not.toThrow();
|
||
});
|
||
|
||
it("显式 kind 优先于 HttpError 推断(新来源自报「要不要人介入」)", () => {
|
||
const rec = new ErrorRecorder(repo, now);
|
||
rec.record({ source: "llm", err: "超时", code: "llm_timeout", kind: "expected" });
|
||
rec.record({ source: "llm", err: "鉴权失败", code: "llm_failed", kind: "unexpected" });
|
||
const rows = db.prepare("SELECT kind, source, status FROM error_records ORDER BY id").all();
|
||
expect(rows[0]).toMatchObject({ kind: "expected", source: "llm", status: null });
|
||
expect(rows[1]).toMatchObject({ kind: "unexpected", source: "llm", status: null });
|
||
});
|
||
|
||
// —— Short-window dedup (the first line of defense against error storms) ——
|
||
|
||
const count = () =>
|
||
db.prepare("SELECT COUNT(*) AS n FROM error_records").get()!.n as unknown as number;
|
||
/** Dedup table (private): asserts the hard requirement that it stays "bounded". */
|
||
const lastSeen = (rec: ErrorRecorder) =>
|
||
(rec as unknown as { lastSeen: Map<string, number> }).lastSeen;
|
||
|
||
it("短窗去重:窗口内的同类异常只落一条,窗口外恢复记录", () => {
|
||
let t = Date.parse("2026-07-06T10:00:00Z");
|
||
const rec = new ErrorRecorder(repo, () => new Date(t));
|
||
const boom = () =>
|
||
rec.record({
|
||
source: "http",
|
||
err: new HttpError(404, "not_found", "没有"),
|
||
ctx: { projectId: "p1" },
|
||
});
|
||
|
||
boom();
|
||
expect(count()).toBe(1);
|
||
|
||
t += DEDUP_WINDOW_MS - 1; // Still within the window: a burst of 404s from a scan discards straight away, no persist
|
||
boom();
|
||
boom();
|
||
expect(count()).toBe(1);
|
||
|
||
t += 1; // Outside the window: the same kind of error is recorded again (a sustained storm leaves exactly one entry per window, never suppressed forever)
|
||
boom();
|
||
expect(count()).toBe(2);
|
||
});
|
||
|
||
it("去重不跨 source / code / Project(不同类的异常互不压制)", () => {
|
||
const rec = new ErrorRecorder(repo, now); // time frozen: everything lands in the same window
|
||
const err = new Error("boom");
|
||
rec.record({ source: "http", err, ctx: { projectId: "p1" }, code: "c1" });
|
||
rec.record({ source: "http", err, ctx: { projectId: "p1" }, code: "c1" }); // same kind: discarded
|
||
rec.record({ source: "http", err, ctx: { projectId: "p1" }, code: "c2" }); // different code
|
||
rec.record({ source: "http", err, ctx: { projectId: "p2" }, code: "c1" }); // different Project
|
||
rec.record({ source: "session", err, ctx: { projectId: "p1" }, code: "c1" }); // different source
|
||
rec.record({ source: "http", err, code: "c1" }); // unattributed (project_id is NULL): counts as its own kind
|
||
expect(count()).toBe(5);
|
||
});
|
||
|
||
it("去重表有界:先清过期项,仍超限则整体清空——清空后照常去重与记录", () => {
|
||
let t = Date.parse("2026-07-06T10:00:00Z");
|
||
const rec = new ErrorRecorder(repo, () => new Date(t));
|
||
const boom = (code: string) =>
|
||
rec.record({ source: "http", err: "boom", ctx: { projectId: "p1" }, code });
|
||
|
||
for (let i = 0; i < DEDUP_KEYS_MAX; i++) boom(`c${i}`); // fill it up (one key per code)
|
||
expect(lastSeen(rec).size).toBe(DEDUP_KEYS_MAX);
|
||
|
||
t += DEDUP_WINDOW_MS; // all old keys expired: the next entry triggers cleanup, leaving only the newly registered one
|
||
boom("after-window");
|
||
expect(lastSeen(rec).size).toBe(1);
|
||
|
||
for (let i = 0; i < DEDUP_KEYS_MAX; i++) boom(`d${i}`); // all within the same window: nothing to clean → wipe the whole table
|
||
expect(lastSeen(rec).size).toBeLessThanOrEqual(DEDUP_KEYS_MAX);
|
||
|
||
// Works normally after being wiped: new errors are still recorded, and duplicates within the window are still discarded.
|
||
const before = count();
|
||
boom("tail");
|
||
boom("tail");
|
||
expect(count()).toBe(before + 1);
|
||
});
|
||
});
|
||
|
||
describe("stream-error-watcher(LLM / Environment 异常)", () => {
|
||
let db: DatabaseSync;
|
||
let repo: ErrorsRepo;
|
||
const now = () => new Date("2026-07-06T10:00:00");
|
||
const CTX = { projectId: "p1", agentId: "a1", sessionId: "s1" };
|
||
|
||
beforeEach(() => {
|
||
db = openDatabase(":memory:");
|
||
repo = new ErrorsRepo(db);
|
||
});
|
||
afterEach(() => db.close());
|
||
|
||
const watcher = () => new StreamErrorWatcher(new ErrorRecorder(repo, now), CTX);
|
||
const rows = () =>
|
||
db.prepare("SELECT * FROM error_records ORDER BY id").all() as Array<Record<string, unknown>>;
|
||
|
||
/** Feeds a sequence of messages and finalizes (close: persists any still-pending failure), returning the persisted rows. */
|
||
function feed(msgs: OmniMessage[]): Array<Record<string, unknown>> {
|
||
const w = watcher();
|
||
for (const m of msgs) w.observe(m);
|
||
w.close();
|
||
return rows();
|
||
}
|
||
|
||
/**
|
||
* A sub-session's session_meta (its first message): origin = the child Session
|
||
* id, and agentId is derived from the parent directory name in the `agent_state`
|
||
* path (consistent with SessionManager.registerChildSession).
|
||
*/
|
||
const childMeta = (sessionId: string, agentState: string) =>
|
||
withOrigin(
|
||
sessionMeta({
|
||
session_id: sessionId,
|
||
model_id: "m1",
|
||
provider: "custom",
|
||
model_context_window: 100000,
|
||
system_prompt: "",
|
||
tools: [],
|
||
thinking_level: "default",
|
||
agent_state: agentState,
|
||
workspace: "/tmp/w",
|
||
}),
|
||
sessionId,
|
||
);
|
||
|
||
// —— LLM ——
|
||
|
||
it("LLM failed → unexpected(不可重试,需人介入);message 取随后 abort 事件的真实原因", () => {
|
||
const got = feed([
|
||
requestBegin(),
|
||
requestEnd("failed"),
|
||
abortEvent("llm request error: 401 invalid api key"),
|
||
]);
|
||
expect(got).toHaveLength(1);
|
||
expect(got[0]).toMatchObject({
|
||
source: "llm",
|
||
kind: "unexpected",
|
||
code: "llm_failed",
|
||
message: "llm request error: 401 invalid api key",
|
||
project_id: "p1",
|
||
agent_id: "a1",
|
||
session_id: "s1",
|
||
status: null,
|
||
});
|
||
});
|
||
|
||
it("LLM timeout / malformed → expected(引擎自动重连重试);重试耗尽时 message 取 abort 原因", () => {
|
||
const got = feed([
|
||
requestBegin(),
|
||
requestEnd("timeout"), // First attempt times out → the engine retries (revealed by the next request_begin: no reason text yet)
|
||
requestBegin(),
|
||
requestEnd("malformed"),
|
||
abortEvent("malformed response failed after 2 retries"),
|
||
]);
|
||
expect(got).toHaveLength(2);
|
||
expect(got[0]).toMatchObject({ source: "llm", kind: "expected", code: "llm_timeout" });
|
||
expect(got[0]!.message).toContain("超时"); // No abort arrived: falls back to the status text
|
||
expect(got[1]).toMatchObject({
|
||
source: "llm",
|
||
kind: "expected",
|
||
code: "llm_malformed",
|
||
message: "malformed response failed after 2 retries",
|
||
});
|
||
});
|
||
|
||
it("aborted(用户点「停止」)不是异常:不记录;completed 亦不记", () => {
|
||
expect(
|
||
feed([
|
||
requestBegin(),
|
||
requestEnd("completed"),
|
||
requestBegin(),
|
||
requestEnd("aborted"),
|
||
abortEvent("aborted by user"),
|
||
]),
|
||
).toHaveLength(0);
|
||
});
|
||
|
||
it("用户在重试退避期间中断:timeout 是真实失败照记,但 message 不采信用户中断文案", () => {
|
||
const got = feed([
|
||
requestBegin(),
|
||
requestEnd("timeout"),
|
||
abortEvent("aborted during reconnect backoff"),
|
||
]);
|
||
expect(got).toHaveLength(1);
|
||
expect(got[0]).toMatchObject({ code: "llm_timeout", kind: "expected" });
|
||
expect(got[0]!.message).toContain("超时");
|
||
expect(got[0]!.message).not.toContain("aborted");
|
||
});
|
||
|
||
it("失败先挂起等原因;run 结束仍未揭晓 → close 兜底落库(用 status 文案)", () => {
|
||
const w = watcher();
|
||
w.observe(requestBegin());
|
||
w.observe(requestEnd("failed"));
|
||
expect(rows()).toHaveLength(0); // Pending: waiting for the abort that immediately follows to supply the real reason
|
||
w.close();
|
||
const got = rows();
|
||
expect(got).toHaveLength(1);
|
||
expect(got[0]).toMatchObject({ code: "llm_failed", kind: "unexpected" });
|
||
expect(got[0]!.message).toContain("LLM 请求失败");
|
||
});
|
||
|
||
it("父/子会话的 LLM 失败按 origin 各自挂起,abort 原因不串味", () => {
|
||
const got = feed([
|
||
requestBegin(), // parent session initiates
|
||
withOrigin(requestBegin(), "session-child"),
|
||
withOrigin(requestEnd("timeout"), "session-child"),
|
||
withOrigin(abortEvent("reconnect failed after 2 retries"), "session-child"),
|
||
requestEnd("failed"), // the parent session's failure only wraps up now
|
||
abortEvent("llm request error: 500 upstream"),
|
||
]);
|
||
expect(got).toHaveLength(2);
|
||
expect(got[0]).toMatchObject({
|
||
code: "llm_timeout",
|
||
message: "reconnect failed after 2 retries",
|
||
});
|
||
expect(got[1]).toMatchObject({
|
||
code: "llm_failed",
|
||
message: "llm request error: 500 upstream", // not stolen by the sub-session's abort
|
||
});
|
||
});
|
||
|
||
// —— Environment (tool execution) ——
|
||
|
||
const call = (name: string, id: string) => toolCall({ name, arguments: "{}", toolCallId: id });
|
||
|
||
it("工具 failed / timeout → environment + expected,code 带工具名(能看出是哪个工具挂了)", () => {
|
||
const got = feed([
|
||
call("exec_command", "tc-1"),
|
||
toolCallOutput({
|
||
output: "ls: /nope: No such file or directory\n[tool error] exit code 2",
|
||
toolCallId: "tc-1",
|
||
stopReason: "failed",
|
||
}),
|
||
call("read_file", "tc-2"),
|
||
toolCallOutput({
|
||
output: "[tool timeout: exceeded 30000ms]",
|
||
toolCallId: "tc-2",
|
||
stopReason: "timeout",
|
||
}),
|
||
]);
|
||
expect(got).toHaveLength(2);
|
||
expect(got[0]).toMatchObject({
|
||
source: "environment",
|
||
kind: "expected", // error fed back to the model; the Agent adjusts on its own — no human needed
|
||
code: "tool_failed:exec_command",
|
||
project_id: "p1",
|
||
agent_id: "a1",
|
||
session_id: "s1",
|
||
});
|
||
expect(got[0]!.message).toContain("[tool error] exit code 2"); // the actual error text
|
||
expect(got[1]).toMatchObject({ code: "tool_timeout:read_file", kind: "expected" });
|
||
});
|
||
|
||
it("工具 aborted(拒批 / 用户中断)与 completed 不记", () => {
|
||
expect(
|
||
feed([
|
||
call("exec_command", "tc-1"),
|
||
toolCallOutput({
|
||
output: "Tool call denied by user.",
|
||
toolCallId: "tc-1",
|
||
stopReason: "aborted",
|
||
}),
|
||
call("read_file", "tc-2"),
|
||
toolCallOutput({ output: "ok", toolCallId: "tc-2", stopReason: "completed" }),
|
||
]),
|
||
).toHaveLength(0);
|
||
});
|
||
|
||
it("并行工具:tool_call_id → 工具名各自对上(输出乱序到达也不错位)", () => {
|
||
const got = feed([
|
||
call("exec_command", "tc-1"),
|
||
call("read_file", "tc-2"),
|
||
call("write_file", "tc-3"),
|
||
toolCallOutput({ output: "boom-2", toolCallId: "tc-2", stopReason: "failed" }),
|
||
toolCallOutput({ output: "ok", toolCallId: "tc-3", stopReason: "completed" }),
|
||
toolCallOutput({ output: "boom-1", toolCallId: "tc-1", stopReason: "failed" }),
|
||
]);
|
||
expect(got.map((r) => r.code)).toEqual(["tool_failed:read_file", "tool_failed:exec_command"]);
|
||
expect(got.map((r) => r.message)).toEqual(["boom-2", "boom-1"]);
|
||
});
|
||
|
||
it("子会话(origin)的工具失败照记:工具名与父会话的同名 tool_call_id 不串味", () => {
|
||
const got = feed([
|
||
call("exec_command", "tc-1"), // parent session
|
||
withOrigin(call("write_file", "tc-1"), "session-child"), // sub-session happens to share the same id
|
||
withOrigin(
|
||
toolCallOutput({ output: "child boom", toolCallId: "tc-1", stopReason: "failed" }),
|
||
"session-child",
|
||
),
|
||
toolCallOutput({ output: "parent boom", toolCallId: "tc-1", stopReason: "failed" }),
|
||
]);
|
||
expect(got).toHaveLength(2);
|
||
expect(got[0]).toMatchObject({
|
||
code: "tool_failed:write_file", // the sub-session's tool name, not overwritten by the parent's tc-1
|
||
message: "child boom",
|
||
session_id: "s1", // this test didn't feed the sub-session's session_meta → attribution falls back to the parent ctx (see the "attribution" test cases below)
|
||
});
|
||
expect(got[1]).toMatchObject({ code: "tool_failed:exec_command", message: "parent boom" });
|
||
});
|
||
|
||
it("超长工具输出:message 取尾部(失败原因在末尾)并截断到上限", () => {
|
||
const got = feed([
|
||
call("exec_command", "tc-1"),
|
||
toolCallOutput({
|
||
output: `${"x".repeat(2000)}\n[tool error] boom`,
|
||
toolCallId: "tc-1",
|
||
stopReason: "failed",
|
||
}),
|
||
]);
|
||
const message = got[0]!.message as string;
|
||
expect(message.length).toBe(MESSAGE_MAX);
|
||
expect(message.startsWith("…")).toBe(true);
|
||
expect(message.endsWith("[tool error] boom")).toBe(true); // truncating from the head would cut off the reason entirely
|
||
});
|
||
|
||
it("无关消息为 no-op:正文、以及流式 partial_*(完整 tool_call_output 才是收尾)", () => {
|
||
expect(
|
||
feed([
|
||
assistantText("正常输出"),
|
||
call("exec_command", "tc-1"),
|
||
partialToolCallOutput({ eventType: "stop", toolCallId: "tc-1", stopReason: "failed" }),
|
||
]),
|
||
).toHaveLength(0);
|
||
});
|
||
|
||
// —— Attribution: an error is recorded against **the session that actually produced it** (a sub-session's failure must not be attributed to the parent Agent) ——
|
||
|
||
it("子会话的 LLM 失败归到子 Agent / 子 Session(不是父的)", () => {
|
||
const got = feed([
|
||
childMeta("session-child", "/data/agents/agent-child/agent_state"),
|
||
withOrigin(requestBegin(), "session-child"),
|
||
withOrigin(requestEnd("failed"), "session-child"),
|
||
withOrigin(abortEvent("llm request error: 401 invalid api key"), "session-child"),
|
||
]);
|
||
expect(got).toHaveLength(1);
|
||
expect(got[0]).toMatchObject({
|
||
source: "llm",
|
||
code: "llm_failed",
|
||
message: "llm request error: 401 invalid api key",
|
||
agent_id: "agent-child", // derived from the agent_state path; not the parent's a1
|
||
session_id: "session-child",
|
||
project_id: "p1", // projectId always takes the parent's (a sub-session is always in the same Project)
|
||
});
|
||
});
|
||
|
||
it("子会话的工具失败同样归到子会话(code 仍带工具名)", () => {
|
||
const got = feed([
|
||
childMeta("session-child", "/data/agents/agent-child/agent_state"),
|
||
withOrigin(call("exec_command", "tc-1"), "session-child"),
|
||
withOrigin(
|
||
toolCallOutput({ output: "child boom", toolCallId: "tc-1", stopReason: "failed" }),
|
||
"session-child",
|
||
),
|
||
]);
|
||
expect(got).toHaveLength(1);
|
||
expect(got[0]).toMatchObject({
|
||
source: "environment",
|
||
code: "tool_failed:exec_command",
|
||
message: "child boom",
|
||
agent_id: "agent-child",
|
||
session_id: "session-child",
|
||
project_id: "p1",
|
||
});
|
||
});
|
||
|
||
it("父子交错到达:各自归各自;主会话(无 origin)的失败仍归父", () => {
|
||
const got = feed([
|
||
requestBegin(), // parent session initiates
|
||
childMeta("session-child", "/data/agents/agent-child/agent_state"),
|
||
withOrigin(requestBegin(), "session-child"),
|
||
withOrigin(requestEnd("timeout"), "session-child"),
|
||
withOrigin(abortEvent("reconnect failed after 2 retries"), "session-child"),
|
||
withOrigin(call("write_file", "tc-9"), "session-child"),
|
||
withOrigin(
|
||
toolCallOutput({ output: "child tool boom", toolCallId: "tc-9", stopReason: "failed" }),
|
||
"session-child",
|
||
),
|
||
call("exec_command", "tc-9"), // parent session happens to share the same id
|
||
toolCallOutput({ output: "parent tool boom", toolCallId: "tc-9", stopReason: "failed" }),
|
||
requestEnd("failed"), // the parent session's LLM failure only wraps up now
|
||
abortEvent("llm request error: 500 upstream"),
|
||
]);
|
||
// The sub-session's LLM / tool failures attribute to it, the parent's to the parent — the four entries never mix (each has a distinct code, so short-window dedup doesn't suppress any of them).
|
||
expect(got.map((r) => [r.code, r.agent_id, r.session_id])).toEqual([
|
||
["llm_timeout", "agent-child", "session-child"],
|
||
["tool_failed:write_file", "agent-child", "session-child"],
|
||
["tool_failed:exec_command", "a1", "s1"],
|
||
["llm_failed", "a1", "s1"],
|
||
]);
|
||
});
|
||
|
||
it("session_meta 还没到就先失败(边界):回退父 ctx,不崩", () => {
|
||
const got = feed([
|
||
withOrigin(requestEnd("failed"), "session-child"), // the sub-session's meta hasn't arrived yet
|
||
withOrigin(abortEvent("llm request error: 500"), "session-child"),
|
||
]);
|
||
expect(got).toHaveLength(1);
|
||
expect(got[0]).toMatchObject({ code: "llm_failed", agent_id: "a1", session_id: "s1" });
|
||
});
|
||
|
||
it("agent_state 路径异常(空串):不登记,回退父 ctx(不写进一个不存在的 agentId)", () => {
|
||
const got = feed([
|
||
childMeta("session-child", ""), // path.basename(path.dirname("")) === "." → caught by the defensive check
|
||
withOrigin(requestEnd("failed"), "session-child"),
|
||
withOrigin(abortEvent("llm request error: 500"), "session-child"),
|
||
]);
|
||
expect(got).toHaveLength(1);
|
||
expect(got[0]).toMatchObject({ code: "llm_failed", agent_id: "a1", session_id: "s1" });
|
||
});
|
||
});
|
||
|
||
describe("HTTP onError 落库(集成)", () => {
|
||
let t: TestApp;
|
||
let api: ReturnType<typeof apiClient>;
|
||
let projectId: string;
|
||
|
||
beforeEach(async () => {
|
||
t = await createTestApp();
|
||
const u = await provisionUser(t.app, "err_user");
|
||
api = apiClient(t.app, u.cookie);
|
||
const created = (await (
|
||
await api.post("/api/projects", { projectId: "err_user-proj", name: "异常项目" })
|
||
).json()) as ProjectCreateResponse;
|
||
projectId = created.project.projectId;
|
||
});
|
||
afterEach(async () => {
|
||
await t.cleanup();
|
||
});
|
||
|
||
const errorRows = () =>
|
||
t.deps.db.prepare("SELECT * FROM error_records ORDER BY id").all() as Array<
|
||
Record<string, unknown>
|
||
>;
|
||
|
||
it("业务错误(HttpError 404)→ expected,带 code / 状态码 / projectId", async () => {
|
||
const res = await api.get(`/api/projects/${projectId}/agents/agent-nope/sessions`);
|
||
expect(res.status).toBe(404);
|
||
|
||
const rows = errorRows();
|
||
expect(rows).toHaveLength(1);
|
||
expect(rows[0]!.source).toBe("http");
|
||
expect(rows[0]!.kind).toBe("expected");
|
||
expect(rows[0]!.code).toBe("agent_not_found");
|
||
expect(rows[0]!.status).toBe(404);
|
||
// Taken from the route params, but only when the requester actually has access — see the "HTTP error attribution" test group below.
|
||
expect(rows[0]!.project_id).toBe(projectId);
|
||
});
|
||
|
||
it("未预期异常(服务层抛普通 Error)→ unexpected + 500", async () => {
|
||
// handleError logs the stack trace: silence it in the test so it doesn't clutter output.
|
||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||
t.deps.usageService.query = () => {
|
||
throw new Error("查询炸了");
|
||
};
|
||
const res = await api.get(`/api/projects/${projectId}/usage?groupBy=date`);
|
||
expect(res.status).toBe(500);
|
||
spy.mockRestore();
|
||
|
||
const rows = errorRows();
|
||
expect(rows).toHaveLength(1);
|
||
expect(rows[0]!.kind).toBe("unexpected");
|
||
expect(rows[0]!.code).toBe("internal");
|
||
expect(rows[0]!.status).toBe(500);
|
||
expect(rows[0]!.message).toBe("查询炸了");
|
||
expect(rows[0]!.project_id).toBe(projectId);
|
||
});
|
||
|
||
it("异常经 usage 端点暴露:汇总 / 最常见错误码 / 最近异常(统计中心的统计信息 + 表格)", async () => {
|
||
// An error in another Project owned by the same owner: attributed to that Project, not this one's view.
|
||
const other = (await (
|
||
await api.post("/api/projects", { projectId: "err_user-proj_2", name: "另一个项目" })
|
||
).json()) as ProjectCreateResponse;
|
||
await api.get(`/api/projects/${other.project.projectId}/agents/agent-nope/sessions`); // 404
|
||
await api.get(`/api/projects/${projectId}/agents/agent-nope/sessions`); // 404 → expected
|
||
|
||
const res = await api.get(`/api/projects/${projectId}/usage?groupBy=date`);
|
||
const body = (await res.json()) as UsageResponse;
|
||
// The entry from another Project doesn't count in this view (only this Project's errors + admin-visible unattributed errors show here).
|
||
expect(body.errors.total).toBe(1);
|
||
expect(body.errors.unexpected).toBe(0);
|
||
expect(body.errors.topCode).toEqual({
|
||
source: "http",
|
||
code: "agent_not_found",
|
||
kind: "expected",
|
||
count: 1,
|
||
});
|
||
expect(body.errors.recent[0]).toMatchObject({ source: "http", code: "agent_not_found" });
|
||
});
|
||
|
||
it("无归属异常(登录失败)只对管理员可见:普通成员的统计中心里看不到别的租户的异常", async () => {
|
||
// A login failure has no Project context → produces one unattributed error (project_id is NULL).
|
||
const bad = await t.app.request("/api/auth/login", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ userId: "err_user", password: "wrong-password" }),
|
||
});
|
||
expect(bad.status).toBe(401);
|
||
expect(errorRows().filter((r) => r.project_id === null)).toHaveLength(1);
|
||
|
||
// A regular member in their own Project: sees none of it.
|
||
const plain = await provisionUser(t.app, "plain_user");
|
||
expect(plain.user.isAdmin).toBe(false);
|
||
const plainApi = apiClient(t.app, plain.cookie);
|
||
const own = (await (
|
||
await plainApi.post("/api/projects", { projectId: "plain_user-proj", name: "普通成员的项目" })
|
||
).json()) as ProjectCreateResponse;
|
||
const plainBody = (await (
|
||
await plainApi.get(`/api/projects/${own.project.projectId}/usage?groupBy=date`)
|
||
).json()) as UsageResponse;
|
||
expect(plainBody.errors).toMatchObject({ total: 0, unexpected: 0, topCode: null, recent: [] });
|
||
|
||
// The admin can see it — the category most in need of visibility isn't rendered invisible by the isolation.
|
||
const adminApi = apiClient(t.app, (await loginAdmin(t.app)).cookie);
|
||
const adminBody = (await (
|
||
await adminApi.get(`/api/projects/default_project/usage?groupBy=date`)
|
||
).json()) as UsageResponse;
|
||
expect(adminBody.errors.total).toBe(1);
|
||
expect(adminBody.errors.topCode).toMatchObject({ source: "http", code: "invalid_credentials" });
|
||
expect(adminBody.errors.recent[0]).toMatchObject({ code: "invalid_credentials" });
|
||
});
|
||
|
||
it("删除 Project 级联清理该 Project 的异常记录", async () => {
|
||
await api.get(`/api/projects/${projectId}/agents/agent-nope/sessions`);
|
||
expect(errorRows().filter((r) => r.project_id === projectId)).toHaveLength(1);
|
||
|
||
const del = await api.delete(`/api/projects/${projectId}`);
|
||
expect(del.status).toBe(204);
|
||
expect(errorRows().filter((r) => r.project_id === projectId)).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
describe("HTTP 异常归属(仅在请求方确有该 Project 访问权时)", () => {
|
||
let t: TestApp;
|
||
/** The built-in admin: unattributed errors are visible only to them. */
|
||
let adminApi: ReturnType<typeof apiClient>;
|
||
let adminProjectId: string;
|
||
/** The victim Project's owner: a regular user, so their stats center only shows errors attributed to this Project. */
|
||
let ownerApi: ReturnType<typeof apiClient>;
|
||
let projectId: string;
|
||
|
||
beforeEach(async () => {
|
||
t = await createTestApp();
|
||
const admin = await loginAdmin(t.app);
|
||
adminApi = apiClient(t.app, admin.cookie);
|
||
adminProjectId = (
|
||
(await (
|
||
await adminApi.post("/api/projects", { projectId: "admin_proj", name: "管理员的项目" })
|
||
).json()) as ProjectCreateResponse
|
||
).project.projectId;
|
||
|
||
const owner = await provisionUser(t.app, "owner_user");
|
||
expect(owner.user.isAdmin).toBe(false);
|
||
ownerApi = apiClient(t.app, owner.cookie);
|
||
projectId = (
|
||
(await (
|
||
await ownerApi.post("/api/projects", { projectId: "owner_user-victim", name: "受害项目" })
|
||
).json()) as ProjectCreateResponse
|
||
).project.projectId;
|
||
});
|
||
afterEach(async () => {
|
||
await t.cleanup();
|
||
});
|
||
|
||
const errorRows = () =>
|
||
t.deps.db.prepare("SELECT * FROM error_records ORDER BY id").all() as Array<
|
||
Record<string, unknown>
|
||
>;
|
||
/** The victim Project's stats center (owner's view: only this Project's errors). */
|
||
const ownerErrors = async () =>
|
||
(
|
||
(await (
|
||
await ownerApi.get(`/api/projects/${projectId}/usage?groupBy=date`)
|
||
).json()) as UsageResponse
|
||
).errors;
|
||
/** The admin's stats center (this Project + unattributed errors). */
|
||
const adminErrors = async () =>
|
||
(
|
||
(await (
|
||
await adminApi.get(`/api/projects/${adminProjectId}/usage?groupBy=date`)
|
||
).json()) as UsageResponse
|
||
).errors;
|
||
|
||
it("未登录 → 401:不归属该 Project(否则任何人都能往别人的 Project 里灌异常统计)", async () => {
|
||
const res = await t.app.request(`/api/projects/${projectId}/usage?groupBy=date`);
|
||
expect(res.status).toBe(401);
|
||
|
||
// The requester isn't even logged in: this error must not be pinned to projectId.
|
||
// Note: today this also **incidentally** relies on a Hono quirk — `c.req.param()`
|
||
// resolves only against "the route the current handler belongs to"; the 401 is
|
||
// thrown in the `/api/*` authMiddleware, whose route has no :projectId, so no
|
||
// value is available there. The attribution guard removes the dependency on
|
||
// that quirk: when not logged in, `c.var.user` is undefined at runtime → unattributed.
|
||
const rows = errorRows();
|
||
expect(rows).toHaveLength(1);
|
||
expect(rows[0]).toMatchObject({ source: "http", code: "unauthorized", status: 401 });
|
||
expect(rows[0]!.project_id).toBeNull();
|
||
|
||
// The owner's stats center gains nothing; the trace of the unauthorized probe lands in the admin's view — right where it belongs.
|
||
expect(await ownerErrors()).toMatchObject({ total: 0, unexpected: 0, topCode: null });
|
||
const admin = await adminErrors();
|
||
expect(admin.total).toBe(1);
|
||
expect(admin.recent[0]).toMatchObject({ source: "http", code: "unauthorized" });
|
||
});
|
||
|
||
it("已登录但无权(非成员)→ 404:同样不归属", async () => {
|
||
const outsider = await provisionUser(t.app, "outsider");
|
||
const res = await apiClient(t.app, outsider.cookie).get(
|
||
`/api/projects/${projectId}/usage?groupBy=date`,
|
||
);
|
||
expect(res.status).toBe(404);
|
||
|
||
const rows = errorRows();
|
||
expect(rows).toHaveLength(1);
|
||
expect(rows[0]).toMatchObject({ source: "http", code: "project_not_found", status: 404 });
|
||
expect(rows[0]!.project_id).toBeNull();
|
||
|
||
expect(await ownerErrors()).toMatchObject({ total: 0, unexpected: 0, topCode: null });
|
||
expect((await adminErrors()).recent[0]).toMatchObject({ code: "project_not_found" });
|
||
});
|
||
|
||
it("有权成员(owner / 被授权 member)触发的业务错误 → 照常归属该 Project", async () => {
|
||
// owner: an invalid groupBy → 400.
|
||
expect((await ownerApi.get(`/api/projects/${projectId}/usage?groupBy=bogus`)).status).toBe(400);
|
||
|
||
// An authorized member: a 404 in the same Project → attributed the same way (the member branch of canAccess).
|
||
const member = await provisionUser(t.app, "member_user");
|
||
const added = await ownerApi.post(`/api/projects/${projectId}/members`, {
|
||
userId: "member_user",
|
||
});
|
||
expect(added.status).toBe(201);
|
||
const missing = await apiClient(t.app, member.cookie).get(
|
||
`/api/projects/${projectId}/agents/agent-nope/sessions`,
|
||
);
|
||
expect(missing.status).toBe(404);
|
||
|
||
expect(errorRows().map((r) => [r.code, r.project_id])).toEqual([
|
||
["bad_request", projectId],
|
||
["agent_not_found", projectId],
|
||
]);
|
||
expect(await ownerErrors()).toMatchObject({ total: 2, unexpected: 0 });
|
||
});
|
||
|
||
it("归属判断自身抛异常:onError 不被搞崩(错误响应照常,异常按无归属落库)", async () => {
|
||
t.deps.projectService.canAccess = () => {
|
||
throw new Error("授权判定炸了");
|
||
};
|
||
const res = await ownerApi.get(`/api/projects/${projectId}/usage?groupBy=bogus`);
|
||
expect(res.status).toBe(400); // still the original business error: not turned into a 500, nor an empty response
|
||
expect(await res.json()).toMatchObject({ error: { code: "bad_request" } });
|
||
|
||
const rows = errorRows();
|
||
expect(rows).toHaveLength(1);
|
||
expect(rows[0]!.code).toBe("bad_request");
|
||
expect(rows[0]!.project_id).toBeNull(); // a failed determination always falls back to unattributed
|
||
});
|
||
});
|