feat(traces): mirror sidebar grouping (workspace mode, category folders, shared components), paginate sessions, title fallback to first prompt (#183)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
89435bf62a
commit
ab388b4f8c
30 files changed
+3144
-718
No files matched your search
@@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { requestBegin, requestEnd, sessionMeta, userText } from "@prismshadow/penguin-core";
|
||||
import type { SessionMetaPayload } from "@prismshadow/penguin-core";
|
||||
import type {
|
||||
AgentTracesResponse,
|
||||
ProjectCreateResponse,
|
||||
TraceAnalysisResponse,
|
||||
TraceEventsResponse,
|
||||
@@ -96,6 +97,86 @@ describe("agent-trace-detail", () => {
|
||||
expect((await owner.get(`${base()}/${UNMANAGED}/9`)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("listing without limit keeps the legacy full shape; with limit it pages Session groups and resolves titles", async () => {
|
||||
// Legacy request: full date drill-down, no paging fields.
|
||||
const legacy = (await (await owner.get(base())).json()) as AgentTracesResponse;
|
||||
expect(legacy.dates.map((d) => d.date)).toEqual(["2026-07-06"]);
|
||||
expect(legacy.sessions).toBeUndefined();
|
||||
expect(legacy.totalSessions).toBeUndefined();
|
||||
|
||||
// Default paged request: the unmanaged Session is CLI-origin (no web sessions-table
|
||||
// row, not subagent/schedule), so the "show CLI sessions" OFF default excludes it
|
||||
// from the listing AND the counts.
|
||||
const hidden = (await (await owner.get(`${base()}?limit=10`)).json()) as AgentTracesResponse;
|
||||
expect(hidden.sessions).toEqual([]);
|
||||
expect(hidden.totalSessions).toBe(0);
|
||||
expect(hidden.counts).toEqual({ active: 0, subagent: 0, schedule: 0, archived: 0 });
|
||||
|
||||
// cli=1: visible, with title derived from the first user prompt and category /
|
||||
// workspace from the registration-time facts (the index head-read the earliest
|
||||
// shard once when the file was first seen — exact on the FIRST request).
|
||||
const paged = (await (
|
||||
await owner.get(`${base()}?limit=10&cli=1`)
|
||||
).json()) as AgentTracesResponse;
|
||||
expect(paged.totalSessions).toBe(1);
|
||||
expect(paged.sessions!.map((s) => s.sessionId)).toEqual([UNMANAGED]);
|
||||
expect(paged.sessions![0]!.title).toBe("child session input");
|
||||
expect(paged.sessions![0]!.files.map((f) => ({ index: f.index, date: f.date }))).toEqual([
|
||||
{ index: 1, date: "2026-07-06" },
|
||||
]);
|
||||
expect(paged.sessions![0]!.category).toBe("active");
|
||||
expect(paged.sessions![0]!.workspace).toBe("/tmp/w");
|
||||
expect(paged.counts).toEqual({ active: 1, subagent: 0, schedule: 0, archived: 0 });
|
||||
expect(paged.workspaceCounts!["/tmp/w"]).toEqual({
|
||||
active: 1,
|
||||
subagent: 0,
|
||||
schedule: 0,
|
||||
archived: 0,
|
||||
});
|
||||
|
||||
// A sessions-table title (one batched lookup for the page) wins over the fallback.
|
||||
t.deps.sessionsRepo.insert({
|
||||
sessionId: UNMANAGED,
|
||||
projectId,
|
||||
agentId: "default_agent",
|
||||
provider: "custom",
|
||||
modelId: "sub-model",
|
||||
workspace: "/tmp/w",
|
||||
approvalMode: "allow-all",
|
||||
title: "已生成的标题",
|
||||
client: "cli",
|
||||
createdAt: "2026-07-06T09:00:00.000Z",
|
||||
});
|
||||
const titled = (await (
|
||||
await owner.get(`${base()}?limit=10&cli=1`)
|
||||
).json()) as AgentTracesResponse;
|
||||
expect(titled.sessions![0]!.title).toBe("已生成的标题");
|
||||
// The adopted row carries client='cli': it stays behind the preference by default.
|
||||
const stillHidden = (await (
|
||||
await owner.get(`${base()}?limit=10`)
|
||||
).json()) as AgentTracesResponse;
|
||||
expect(stillHidden.sessions).toEqual([]);
|
||||
|
||||
// offset pages past the only group; offset without limit is a client error.
|
||||
const empty = (await (
|
||||
await owner.get(`${base()}?limit=10&offset=10&cli=1`)
|
||||
).json()) as AgentTracesResponse;
|
||||
expect(empty.sessions).toEqual([]);
|
||||
expect(empty.totalSessions).toBe(1);
|
||||
expect((await owner.get(`${base()}?offset=1`)).status).toBe(400);
|
||||
|
||||
// Category filter: pages within one bucket (totalSessions = the bucket's count);
|
||||
// unknown values, category-without-limit, and a bad cli value are client errors.
|
||||
const filtered = (await (
|
||||
await owner.get(`${base()}?limit=10&category=subagent&cli=1`)
|
||||
).json()) as AgentTracesResponse;
|
||||
expect(filtered.sessions).toEqual([]);
|
||||
expect(filtered.totalSessions).toBe(0);
|
||||
expect((await owner.get(`${base()}?limit=10&category=bogus`)).status).toBe(400);
|
||||
expect((await owner.get(`${base()}?category=active`)).status).toBe(400);
|
||||
expect((await owner.get(`${base()}?limit=10&cli=2`)).status).toBe(400);
|
||||
});
|
||||
|
||||
it("user without access → 404 (requireProjectAccess)", async () => {
|
||||
expect((await outsider.get(`${base()}/${UNMANAGED}/1`)).status).toBe(404);
|
||||
expect((await outsider.get(`${base()}/${UNMANAGED}/1/analysis`)).status).toBe(404);
|
||||
|
||||
@@ -10,6 +10,12 @@ import type { Hono } from "hono";
|
||||
import type { OmniMessage } from "@prismshadow/penguin-core";
|
||||
import { buildAppDeps, createApp } from "../src/app.js";
|
||||
import type { AppDeps, BuildDepsOverrides } from "../src/app.js";
|
||||
import { openDatabase } from "../src/db/database.js";
|
||||
import { TraceIndexRepo } from "../src/db/repos/trace-index.js";
|
||||
import { SessionSources } from "../src/runtime/session-sources.js";
|
||||
import { TraceIndexService } from "../src/services/trace-index.js";
|
||||
import { TraceService } from "../src/services/trace-service.js";
|
||||
import type { TraceSessionIndex } from "../src/services/trace-service.js";
|
||||
import type { AppEnv } from "../src/auth/middleware.js";
|
||||
import { ADMIN_USER_ID } from "../src/auth/service.js";
|
||||
import type { ServerConfig } from "../src/config.js";
|
||||
@@ -146,6 +152,31 @@ export function apiClient(app: Hono<AppEnv>, cookie: string) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Index-backed TraceService for pure service tests: an in-memory DB with the real
|
||||
* schema, a real reconciler over the temp root, and a shared origin registry — the
|
||||
* same wiring app.ts assembles, minus the HTTP app.
|
||||
*/
|
||||
export function makeTraceHarness(
|
||||
root: string,
|
||||
opts: { sessions?: TraceSessionIndex; sources?: SessionSources } = {},
|
||||
): {
|
||||
traceIndex: TraceIndexService;
|
||||
service: TraceService;
|
||||
sources: SessionSources;
|
||||
close: () => void;
|
||||
} {
|
||||
const db = openDatabase(":memory:");
|
||||
const sources = opts.sources ?? new SessionSources();
|
||||
const traceIndex = new TraceIndexService(root, new TraceIndexRepo(db), sources);
|
||||
const service = new TraceService(root, {
|
||||
index: traceIndex,
|
||||
...(opts.sessions !== undefined ? { sessions: opts.sessions } : {}),
|
||||
sources,
|
||||
});
|
||||
return { traceIndex, service, sources, close: () => db.close() };
|
||||
}
|
||||
|
||||
/** Writes a Trace JSONL file directly (for building historical / discovery scenarios). */
|
||||
export async function writeTraceFile(
|
||||
root: string,
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Trace-file index tests: mtime-gated reconciliation (an unchanged tree costs zero
|
||||
* directory scans), registration-time classification, gate blind spots recovered by
|
||||
* the consumers' force-retry, write-time registration (import) and delete coherence.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { sessionMeta, userText } from "@prismshadow/penguin-core";
|
||||
import type { SessionMetaPayload } from "@prismshadow/penguin-core";
|
||||
import { makeTempRoot, makeTraceHarness, writeTraceFile } from "./helpers.js";
|
||||
|
||||
const P = "project-i";
|
||||
const A = "agent-i";
|
||||
const S1 = "session-2026-07-05-10-00-00-aabb0001";
|
||||
const S2 = "session-2026-07-06-09-00-00-aabb0002";
|
||||
const OLD = "session-2026-07-01-08-00-00-aabb0003";
|
||||
|
||||
/**
|
||||
* Ages the given paths' mtimes past the reconciler's FRESH_MS window: freshly written
|
||||
* dirs are deliberately never cached as clean (same-tick change hazard), so steady-state
|
||||
* assertions first make the tree look quiet, exactly like a tree written minutes ago.
|
||||
*/
|
||||
async function backdate(...paths: string[]): Promise<void> {
|
||||
// A FIXED past instant, so repeated backdates are idempotent: the blind-spot test
|
||||
// resets a changed dir to exactly the mtime the gate cached, which is precisely the
|
||||
// "old dir changed without moving any gated mtime" scenario.
|
||||
const old = new Date("2026-01-01T00:00:00.000Z");
|
||||
for (const p of paths) {
|
||||
await fs.utimes(p, old, old);
|
||||
}
|
||||
}
|
||||
|
||||
function tracesRoot(root: string): string {
|
||||
return path.join(root, P, "agents", A, "traces");
|
||||
}
|
||||
|
||||
function meta(sessionId: string, over: Partial<SessionMetaPayload> = {}): SessionMetaPayload {
|
||||
return {
|
||||
session_id: sessionId,
|
||||
model_id: "m1",
|
||||
provider: "custom",
|
||||
model_context_window: 1000,
|
||||
system_prompt: "sp",
|
||||
tools: [],
|
||||
agent_state: "/tmp/a",
|
||||
workspace: "/ws/one",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("trace-index", () => {
|
||||
let root: string;
|
||||
let h: ReturnType<typeof makeTraceHarness>;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await makeTempRoot();
|
||||
h = makeTraceHarness(root);
|
||||
});
|
||||
afterEach(async () => {
|
||||
h.close();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("registers external files on reconcile and classifies each Session once; an unchanged tree costs zero directory scans", async () => {
|
||||
await writeTraceFile(root, P, A, "2026-07-05", S1, 1, [
|
||||
sessionMeta(meta(S1, { source: "subagent" })),
|
||||
userText("你好,请检查代码"),
|
||||
]);
|
||||
await backdate(tracesRoot(root), path.join(tracesRoot(root), "2026-07-05"));
|
||||
await h.traceIndex.reconcileAgent(P, A);
|
||||
const rows = h.traceIndex.repo.listFilesByAgent(P, A);
|
||||
expect(
|
||||
rows.map((r) => ({ sessionId: r.sessionId, fileIndex: r.fileIndex, date: r.date })),
|
||||
).toEqual([{ sessionId: S1, fileIndex: 1, date: "2026-07-05" }]);
|
||||
expect(rows[0]!.sizeBytes).toBeGreaterThan(0);
|
||||
// Registration-time facts: origin / workspace / first-prompt title, one head-read.
|
||||
const facts = h.traceIndex.repo.getSession(S1);
|
||||
expect(facts?.source).toBe("subagent");
|
||||
expect(facts?.workspace).toBe("/ws/one");
|
||||
expect(facts?.title).toBe("你好,请检查代码");
|
||||
expect(facts?.metaRead).toBe(true);
|
||||
expect(h.sources.get(S1)).toBe("subagent");
|
||||
expect(h.traceIndex.counters.headReads).toBe(1);
|
||||
|
||||
// Steady state: reconciling an unchanged tree is pure gate stats — no readdir, no reads.
|
||||
const scans = h.traceIndex.counters.dirScans;
|
||||
await h.traceIndex.reconcileAgent(P, A);
|
||||
await h.traceIndex.reconcileAgent(P, A);
|
||||
expect(h.traceIndex.counters.dirScans).toBe(scans);
|
||||
expect(h.traceIndex.counters.headReads).toBe(1);
|
||||
|
||||
// A new shard in the same (newest) date dir moves that dir's mtime: the gate notices.
|
||||
await writeTraceFile(root, P, A, "2026-07-05", S2, 1, [
|
||||
sessionMeta(meta(S2)),
|
||||
userText("second session"),
|
||||
]);
|
||||
await h.traceIndex.reconcileAgent(P, A);
|
||||
expect(h.traceIndex.repo.listFilesByAgent(P, A)).toHaveLength(2);
|
||||
expect(h.traceIndex.counters.dirScans).toBe(scans + 1);
|
||||
expect(h.traceIndex.repo.getSession(S2)?.metaRead).toBe(true);
|
||||
});
|
||||
|
||||
it("a write into an OLD date dir slips past the gate; the consumers' force-retry (locateAll) recovers it", async () => {
|
||||
// Two date dirs indexed; the gate then watches the root + the NEWEST dir only.
|
||||
await writeTraceFile(root, P, A, "2026-07-01", OLD, 1, [userText("old day")]);
|
||||
await writeTraceFile(root, P, A, "2026-07-05", S1, 1, [userText("new day")]);
|
||||
await backdate(
|
||||
tracesRoot(root),
|
||||
path.join(tracesRoot(root), "2026-07-01"),
|
||||
path.join(tracesRoot(root), "2026-07-05"),
|
||||
);
|
||||
await h.traceIndex.reconcileAgent(P, A);
|
||||
expect(h.traceIndex.repo.listFilesByAgent(P, A)).toHaveLength(2);
|
||||
|
||||
// External writer appends a SECOND shard into the old dir: neither gated mtime
|
||||
// (root / newest dir) moves. Backdating the old dir again keeps its change invisible
|
||||
// even to a freshness-triggered re-diff — the pure gate blind spot.
|
||||
await writeTraceFile(root, P, A, "2026-07-01", OLD, 2, [userText("late arrival")]);
|
||||
await backdate(tracesRoot(root), path.join(tracesRoot(root), "2026-07-01"));
|
||||
await h.traceIndex.reconcileAgent(P, A);
|
||||
expect(h.traceIndex.repo.listFilesBySession(P, A, OLD)).toHaveLength(1); // gate blind spot
|
||||
|
||||
// A consumer that misses must force-reconcile and retry rather than 404: the
|
||||
// service's listTraceFiles goes through locateAll, which does exactly that when the
|
||||
// requested index is absent... locateAll's whole-session miss is the trigger, so
|
||||
// exercise it via a session the index does not know at all.
|
||||
const LATE = "session-2026-07-01-09-00-00-aabb0009";
|
||||
await writeTraceFile(root, P, A, "2026-07-01", LATE, 1, [userText("unseen session")]);
|
||||
await backdate(tracesRoot(root), path.join(tracesRoot(root), "2026-07-01"));
|
||||
const files = await h.service.listTraceFiles(P, A, LATE);
|
||||
expect(files.map((f) => f.index)).toEqual([1]);
|
||||
// The forced pass also swept in the other blind-spot shard.
|
||||
expect(h.traceIndex.repo.listFilesBySession(P, A, OLD)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("import registers synchronously (rows + facts, no re-read); Session delete removes files and rows", async () => {
|
||||
const IMP = "session-2026-07-03-12-00-00-aabb0004";
|
||||
const lines = [
|
||||
sessionMeta(meta(IMP, { source: "schedule", workspace: "/ws/imp" })),
|
||||
userText("imported prompt"),
|
||||
]
|
||||
.map((m) => JSON.stringify({ ...m, timestamp: "2026-07-03T12:00:00.000Z" }))
|
||||
.join("\n");
|
||||
const headReads = h.traceIndex.counters.headReads;
|
||||
const res = await h.service.importTraceFile(P, A, lines);
|
||||
expect(res).toEqual({ sessionId: IMP, index: 1, date: "2026-07-03" });
|
||||
// Registered without any head-read (the records were already in memory).
|
||||
expect(h.traceIndex.counters.headReads).toBe(headReads);
|
||||
expect(h.traceIndex.repo.listFilesBySession(P, A, IMP)).toHaveLength(1);
|
||||
const facts = h.traceIndex.repo.getSession(IMP);
|
||||
expect(facts?.source).toBe("schedule");
|
||||
expect(facts?.workspace).toBe("/ws/imp");
|
||||
expect(facts?.title).toBe("imported prompt");
|
||||
|
||||
await h.service.deleteSessionTraces(P, A, IMP);
|
||||
expect(h.traceIndex.repo.listFilesBySession(P, A, IMP)).toEqual([]);
|
||||
expect(h.traceIndex.repo.getSession(IMP)).toBeNull();
|
||||
// The files are gone from disk too, and a fresh reconcile does not resurrect rows.
|
||||
await h.traceIndex.reconcileAgent(P, A, { force: true });
|
||||
expect(h.traceIndex.repo.listFilesBySession(P, A, IMP)).toEqual([]);
|
||||
});
|
||||
|
||||
it("externally deleted files and date dirs fall out of the index on the next changed-dir pass", async () => {
|
||||
await writeTraceFile(root, P, A, "2026-07-05", S1, 1, [userText("a")]);
|
||||
await writeTraceFile(root, P, A, "2026-07-05", S1, 2, [userText("b")]);
|
||||
await h.traceIndex.reconcileAgent(P, A);
|
||||
expect(h.traceIndex.repo.listFilesBySession(P, A, S1)).toHaveLength(2);
|
||||
|
||||
const dir = path.join(root, P, "agents", A, "traces", "2026-07-05");
|
||||
await fs.rm(path.join(dir, `${S1}_002.jsonl`));
|
||||
await h.traceIndex.reconcileAgent(P, A); // the dir's mtime moved: rescanned
|
||||
expect(h.traceIndex.repo.listFilesBySession(P, A, S1).map((r) => r.fileIndex)).toEqual([1]);
|
||||
|
||||
await fs.rm(dir, { recursive: true });
|
||||
await h.traceIndex.reconcileAgent(P, A); // the root's mtime moved: date dir purged
|
||||
expect(h.traceIndex.repo.listFilesByAgent(P, A)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -23,8 +23,10 @@ import {
|
||||
withOrigin,
|
||||
} from "@prismshadow/penguin-core";
|
||||
import type { OmniMessage, SessionMetaPayload, TokenCounts } from "@prismshadow/penguin-core";
|
||||
import { TraceService } from "../src/services/trace-service.js";
|
||||
import { makeTempRoot, writeTraceFile } from "./helpers.js";
|
||||
import type { TraceService } from "../src/services/trace-service.js";
|
||||
import type { SessionRow } from "../src/db/repos/sessions.js";
|
||||
import { SessionSources } from "../src/runtime/session-sources.js";
|
||||
import { makeTempRoot, makeTraceHarness, writeTraceFile } from "./helpers.js";
|
||||
|
||||
const P = "project-t";
|
||||
const A = "agent-t";
|
||||
@@ -48,7 +50,7 @@ function buckets(cacheRead: number, cacheWrite: number, output: number): TokenCo
|
||||
};
|
||||
}
|
||||
|
||||
function metaPayload(): SessionMetaPayload {
|
||||
function metaPayload(over: Partial<SessionMetaPayload> = {}): SessionMetaPayload {
|
||||
return {
|
||||
session_id: S,
|
||||
model_id: "m1",
|
||||
@@ -58,18 +60,37 @@ function metaPayload(): SessionMetaPayload {
|
||||
tools: [],
|
||||
agent_state: "/tmp/a",
|
||||
workspace: "/tmp/w",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/** A sessions-table row for the paginated listing's index fake (only the listing-relevant fields vary per test). */
|
||||
function dbRow(over: Partial<SessionRow> & { sessionId: string }): SessionRow {
|
||||
return {
|
||||
projectId: P,
|
||||
agentId: A,
|
||||
provider: "custom",
|
||||
modelId: "m1",
|
||||
workspace: "/tmp/w",
|
||||
approvalMode: "allow-all",
|
||||
title: null,
|
||||
createdAt: "2026-07-05T10:00:00.000Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("trace-service", () => {
|
||||
let root: string;
|
||||
let service: TraceService;
|
||||
let harness: ReturnType<typeof makeTraceHarness>;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await makeTempRoot();
|
||||
service = new TraceService(root);
|
||||
harness = makeTraceHarness(root);
|
||||
service = harness.service;
|
||||
});
|
||||
afterEach(async () => {
|
||||
harness.close();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -530,6 +551,175 @@ describe("trace-service", () => {
|
||||
expect(res.dates.map((d) => d.date)).toEqual(["2026-07-06", "2026-07-05"]);
|
||||
expect(res.dates[1]!.sessions[0]!.sessionId).toBe(S);
|
||||
expect(res.dates[1]!.sessions[0]!.files.map((f) => f.index)).toEqual([1, 2]);
|
||||
// No `limit` -> the legacy full shape only: the paging fields must stay absent
|
||||
// (existing consumers see a byte-identical response).
|
||||
expect(res.sessions).toBeUndefined();
|
||||
expect(res.totalSessions).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Agent-level paging: slices Session groups newest-first, merges one Session's files across dates, and carries the total", async () => {
|
||||
const s2 = "session-2026-07-06-09-00-00-11112222";
|
||||
const s3 = "session-2026-07-07-08-00-00-33334444";
|
||||
// S spans two date directories: the paged entry must merge them with a per-file date.
|
||||
await writeTraceFile(root, P, A, "2026-07-05", S, 1, [userText("a")]);
|
||||
await writeTraceFile(root, P, A, "2026-07-06", S, 2, [userText("b")]);
|
||||
await writeTraceFile(root, P, A, "2026-07-06", s2, 1, [userText("c")]);
|
||||
await writeTraceFile(root, P, A, "2026-07-07", s3, 1, [userText("d")]);
|
||||
|
||||
const page1 = await service.agentTraces(P, A, { offset: 0, limit: 2 }, { includeCli: true });
|
||||
expect(page1.totalSessions).toBe(3);
|
||||
expect(page1.dates).toEqual([]); // paged responses are session-centric; per-file stats happen only for the slice
|
||||
expect(page1.sessions!.map((s) => s.sessionId)).toEqual([s3, s2]);
|
||||
|
||||
const page2 = await service.agentTraces(P, A, { offset: 2, limit: 2 }, { includeCli: true });
|
||||
expect(page2.totalSessions).toBe(3);
|
||||
expect(page2.sessions!.map((s) => s.sessionId)).toEqual([S]);
|
||||
expect(page2.sessions![0]!.files.map((f) => ({ index: f.index, date: f.date }))).toEqual([
|
||||
{ index: 1, date: "2026-07-05" },
|
||||
{ index: 2, date: "2026-07-06" },
|
||||
]);
|
||||
expect(page2.sessions![0]!.files.every((f) => f.sizeBytes > 0)).toBe(true);
|
||||
|
||||
// Paging an empty Agent stays well-formed.
|
||||
const empty = await service.agentTraces(
|
||||
P,
|
||||
"agent-none",
|
||||
{ offset: 0, limit: 2 },
|
||||
{ includeCli: true },
|
||||
);
|
||||
expect(empty.sessions).toEqual([]);
|
||||
expect(empty.totalSessions).toBe(0);
|
||||
});
|
||||
|
||||
it("Agent-level paging: the sessions DB title wins over the first-prompt fallback", async () => {
|
||||
const withTitles = makeTraceHarness(root, {
|
||||
sessions: { listByAgent: () => [dbRow({ sessionId: S, title: "已生成的标题" })] },
|
||||
});
|
||||
await writeTraceFile(root, P, A, "2026-07-05", S, 1, [
|
||||
sessionMeta(metaPayload()),
|
||||
userText("raw prompt text"),
|
||||
]);
|
||||
const res = await withTitles.service.agentTraces(P, A, { offset: 0, limit: 10 });
|
||||
expect(res.sessions![0]!.title).toBe("已生成的标题");
|
||||
withTitles.close();
|
||||
});
|
||||
|
||||
it("Agent-level paging: category/workspace come from the DB row (archived wins; a registry-known origin fills its bucket)", async () => {
|
||||
const s2 = "session-2026-07-06-09-00-00-11112222";
|
||||
const s3 = "session-2026-07-07-08-00-00-33334444";
|
||||
await writeTraceFile(root, P, A, "2026-07-05", S, 1, [userText("a")]);
|
||||
await writeTraceFile(root, P, A, "2026-07-06", s2, 1, [userText("b")]);
|
||||
await writeTraceFile(root, P, A, "2026-07-07", s3, 1, [userText("c")]);
|
||||
const sources = new SessionSources();
|
||||
sources.set(s2, "subagent");
|
||||
sources.set(s3, null); // meta seen, user-created
|
||||
const h = makeTraceHarness(root, {
|
||||
sources,
|
||||
sessions: {
|
||||
listByAgent: () => [
|
||||
dbRow({ sessionId: S, workspace: "/ws/one", archivedAt: "2026-07-08T00:00:00.000Z" }),
|
||||
dbRow({ sessionId: s3, workspace: "/ws/two" }),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const res = await h.service.agentTraces(P, A, { offset: 0, limit: 10 }, { includeCli: true });
|
||||
const byId = new Map(res.sessions!.map((x) => [x.sessionId, x]));
|
||||
expect(byId.get(S)!.category).toBe("archived");
|
||||
expect(byId.get(S)!.workspace).toBe("/ws/one");
|
||||
expect(byId.get(s2)!.category).toBe("subagent"); // untracked but registry-known
|
||||
expect(byId.get(s3)!.category).toBe("active");
|
||||
expect(res.counts).toEqual({ active: 1, subagent: 1, schedule: 0, archived: 1 });
|
||||
expect(res.workspaceCounts!["/ws/one"]).toEqual({
|
||||
active: 0,
|
||||
subagent: 0,
|
||||
schedule: 0,
|
||||
archived: 1,
|
||||
});
|
||||
expect(res.workspaceCounts!["/ws/two"]).toEqual({
|
||||
active: 1,
|
||||
subagent: 0,
|
||||
schedule: 0,
|
||||
archived: 0,
|
||||
});
|
||||
|
||||
// The category filter pages within one bucket; totalSessions is the bucket's count.
|
||||
const active = await h.service.agentTraces(
|
||||
P,
|
||||
A,
|
||||
{ offset: 0, limit: 10 },
|
||||
{ category: "active", includeCli: true },
|
||||
);
|
||||
expect(active.sessions!.map((x) => x.sessionId)).toEqual([s3]);
|
||||
expect(active.totalSessions).toBe(1);
|
||||
expect(active.counts).toEqual({ active: 1, subagent: 1, schedule: 0, archived: 1 });
|
||||
const archived = await h.service.agentTraces(
|
||||
P,
|
||||
A,
|
||||
{ offset: 0, limit: 10 },
|
||||
{ category: "archived", includeCli: true },
|
||||
);
|
||||
expect(archived.sessions!.map((x) => x.sessionId)).toEqual([S]);
|
||||
h.close();
|
||||
});
|
||||
|
||||
it("Agent-level paging: an untracked Session is classified at registration (head-read once), so the FIRST listing is exact", async () => {
|
||||
// No DB row, nothing pre-registered: the reconciler registers the file and reads the
|
||||
// earliest shard's session_meta ONCE; the listing itself then serves category /
|
||||
// workspace / title from the index with no per-request head-reads.
|
||||
await writeTraceFile(root, P, A, "2026-07-05", S, 1, [
|
||||
sessionMeta(metaPayload({ source: "subagent", workspace: "/ws/child" })),
|
||||
userText("child prompt"),
|
||||
]);
|
||||
|
||||
const first = await service.agentTraces(P, A, { offset: 0, limit: 10 }, { includeCli: true });
|
||||
expect(first.sessions![0]!.category).toBe("subagent");
|
||||
expect(first.sessions![0]!.workspace).toBe("/ws/child");
|
||||
expect(first.counts).toEqual({ active: 0, subagent: 1, schedule: 0, archived: 0 });
|
||||
// The observation landed in the shared registry (single source of truth) at registration.
|
||||
expect(harness.sources.get(S)).toBe("subagent");
|
||||
const reads = harness.traceIndex.counters.headReads;
|
||||
|
||||
const second = await service.agentTraces(P, A, { offset: 0, limit: 10 }, { includeCli: true });
|
||||
expect(second.sessions![0]!.category).toBe("subagent");
|
||||
expect(harness.traceIndex.counters.headReads).toBe(reads); // classification never re-reads
|
||||
});
|
||||
|
||||
it("Agent-level paging: CLI-origin Sessions are hidden by default and shown with includeCli (subagent/schedule stay visible)", async () => {
|
||||
const cliSid = "session-2026-07-06-09-00-00-11112222"; // untracked, user-created meta -> CLI-origin
|
||||
const childSid = "session-2026-07-07-08-00-00-33334444"; // untracked but subagent-origin -> its folder, regardless
|
||||
await writeTraceFile(root, P, A, "2026-07-06", cliSid, 1, [
|
||||
sessionMeta(metaPayload({ session_id: cliSid })),
|
||||
userText("cli run"),
|
||||
]);
|
||||
await writeTraceFile(root, P, A, "2026-07-07", childSid, 1, [
|
||||
sessionMeta(metaPayload({ session_id: childSid, source: "subagent" })),
|
||||
userText("child run"),
|
||||
]);
|
||||
const webSid = S;
|
||||
await writeTraceFile(root, P, A, "2026-07-05", webSid, 1, [
|
||||
sessionMeta(metaPayload()),
|
||||
userText("web run"),
|
||||
]);
|
||||
const h = makeTraceHarness(root, {
|
||||
sessions: { listByAgent: () => [dbRow({ sessionId: webSid, workspace: "/ws/web" })] },
|
||||
});
|
||||
|
||||
const hidden = await h.service.agentTraces(P, A, { offset: 0, limit: 10 });
|
||||
expect(hidden.sessions!.map((x) => x.sessionId)).toEqual([childSid, webSid]);
|
||||
expect(hidden.totalSessions).toBe(2);
|
||||
expect(hidden.counts).toEqual({ active: 1, subagent: 1, schedule: 0, archived: 0 });
|
||||
|
||||
const shown = await h.service.agentTraces(P, A, { offset: 0, limit: 10 }, { includeCli: true });
|
||||
expect(shown.sessions!.map((x) => x.sessionId)).toEqual([childSid, cliSid, webSid]);
|
||||
expect(shown.counts).toEqual({ active: 2, subagent: 1, schedule: 0, archived: 0 });
|
||||
h.close();
|
||||
});
|
||||
|
||||
it("Agent-level paging: a Session whose head has no user text gets no title (the client falls back to its default)", async () => {
|
||||
await writeTraceFile(root, P, A, "2026-07-05", S, 1, [sessionMeta(metaPayload())]);
|
||||
const res = await service.agentTraces(P, A, { offset: 0, limit: 10 }, { includeCli: true });
|
||||
expect(res.sessions![0]!.title).toBeUndefined();
|
||||
});
|
||||
|
||||
it("every endpoint returns empty when there is no Trace", async () => {
|
||||
|
||||
@@ -22,7 +22,8 @@ import {
|
||||
userText,
|
||||
} from "@prismshadow/penguin-core";
|
||||
import type { OmniMessage, SessionMetaPayload } from "@prismshadow/penguin-core";
|
||||
import { TraceService } from "../src/services/trace-service.js";
|
||||
import type { TraceService } from "../src/services/trace-service.js";
|
||||
import { makeTraceHarness } from "./helpers.js";
|
||||
|
||||
const PROJECT = "proj";
|
||||
const PARENT_AGENT = "default_agent";
|
||||
@@ -63,12 +64,15 @@ async function writeTrace(
|
||||
describe("TraceService.readMessages — sub-session expansion", () => {
|
||||
let root: string;
|
||||
let svc: TraceService;
|
||||
let harness: ReturnType<typeof makeTraceHarness>;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), "penguin-trace-expand-"));
|
||||
svc = new TraceService(root);
|
||||
harness = makeTraceHarness(root);
|
||||
svc = harness.service;
|
||||
});
|
||||
afterEach(async () => {
|
||||
harness.close();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user