mirror of
https://github.com/earendil-works/pi.git
synced 2026-06-18 15:54:04 +08:00
Add session analytics projection
This commit is contained in:
@@ -74,4 +74,18 @@ export {
|
||||
type TurnStartEvent,
|
||||
type WorkingIndicatorOptions,
|
||||
} from "./extensions/index.ts";
|
||||
export {
|
||||
hashSessionAnalyticsString,
|
||||
type ProjectSessionAnalyticsOptions,
|
||||
type ProjectSessionHeaderAnalyticsOptions,
|
||||
projectSessionEntryForAnalytics,
|
||||
projectSessionForAnalytics,
|
||||
projectSessionHeaderForAnalytics,
|
||||
SESSION_ANALYTICS_SCHEMA_VERSION,
|
||||
type SessionAnalyticsContentStats,
|
||||
type SessionAnalyticsEntryRecord,
|
||||
type SessionAnalyticsRecord,
|
||||
type SessionAnalyticsSessionRecord,
|
||||
type SessionAnalyticsUsage,
|
||||
} from "./session-analytics.ts";
|
||||
export { createSyntheticSourceInfo } from "./source-info.ts";
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
import { createHash } from "crypto";
|
||||
import type { SessionEntry, SessionHeader } from "./session-manager.ts";
|
||||
|
||||
export const SESSION_ANALYTICS_SCHEMA_VERSION = 1;
|
||||
|
||||
export interface SessionAnalyticsUsage {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
totalTokens: number;
|
||||
costInput: number;
|
||||
costOutput: number;
|
||||
costCacheRead: number;
|
||||
costCacheWrite: number;
|
||||
costTotal: number;
|
||||
}
|
||||
|
||||
export interface SessionAnalyticsContentStats {
|
||||
stringContent: boolean;
|
||||
textBlocks: number;
|
||||
imageBlocks: number;
|
||||
thinkingBlocks: number;
|
||||
redactedThinkingBlocks: number;
|
||||
toolCallBlocks: number;
|
||||
otherBlocks: number;
|
||||
}
|
||||
|
||||
export interface SessionAnalyticsSessionRecord {
|
||||
recordType: "session";
|
||||
schemaVersion: typeof SESSION_ANALYTICS_SCHEMA_VERSION;
|
||||
sessionId: string;
|
||||
version?: number;
|
||||
createdAt?: string;
|
||||
modifiedAt?: string;
|
||||
parentSessionHash?: string;
|
||||
cwd?: string;
|
||||
cwdHash?: string;
|
||||
hasCwd: boolean;
|
||||
}
|
||||
|
||||
export interface SessionAnalyticsEntryRecord {
|
||||
recordType: "entry";
|
||||
schemaVersion: typeof SESSION_ANALYTICS_SCHEMA_VERSION;
|
||||
sessionId: string;
|
||||
entryId: string;
|
||||
parentEntryId: string | null;
|
||||
entryType: string;
|
||||
timestamp: string;
|
||||
|
||||
// Message-level metadata. Raw content, tool arguments, thinking text, and errors are intentionally omitted.
|
||||
role?: string;
|
||||
api?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
responseModel?: string;
|
||||
stopReason?: string;
|
||||
hasError?: boolean;
|
||||
usage?: SessionAnalyticsUsage;
|
||||
contentStats?: SessionAnalyticsContentStats;
|
||||
isError?: boolean;
|
||||
exitCode?: number;
|
||||
cancelled?: boolean;
|
||||
truncated?: boolean;
|
||||
excludeFromContext?: boolean;
|
||||
|
||||
// Non-message entry metadata. Raw summaries, labels, names, custom data, and details are intentionally omitted.
|
||||
modelId?: string;
|
||||
thinkingLevel?: string;
|
||||
activeToolCount?: number;
|
||||
firstKeptEntryId?: string;
|
||||
tokensBefore?: number;
|
||||
fromHook?: boolean;
|
||||
hasDetails?: boolean;
|
||||
fromId?: string;
|
||||
customType?: string;
|
||||
display?: boolean;
|
||||
hasData?: boolean;
|
||||
targetId?: string | null;
|
||||
hasLabel?: boolean;
|
||||
hasName?: boolean;
|
||||
}
|
||||
|
||||
export type SessionAnalyticsRecord = SessionAnalyticsSessionRecord | SessionAnalyticsEntryRecord;
|
||||
|
||||
export interface ProjectSessionHeaderAnalyticsOptions {
|
||||
modifiedAt?: Date | string;
|
||||
hashString?: (value: string) => string;
|
||||
}
|
||||
|
||||
export interface ProjectSessionAnalyticsOptions extends ProjectSessionHeaderAnalyticsOptions {}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function maybeString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function maybeNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number {
|
||||
return maybeNumber(value) ?? 0;
|
||||
}
|
||||
|
||||
function modifiedAtToString(value: Date | string | undefined): string | undefined {
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
return maybeString(value);
|
||||
}
|
||||
|
||||
function cleanRecord<T extends object>(record: T): T {
|
||||
const mutableRecord = record as Record<string, unknown>;
|
||||
for (const key of Object.keys(mutableRecord)) {
|
||||
if (mutableRecord[key] === undefined) delete mutableRecord[key];
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
export function hashSessionAnalyticsString(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
export function projectSessionHeaderForAnalytics(
|
||||
header: SessionHeader,
|
||||
options: ProjectSessionHeaderAnalyticsOptions = {},
|
||||
): SessionAnalyticsSessionRecord {
|
||||
const hash = options.hashString ?? hashSessionAnalyticsString;
|
||||
return cleanRecord({
|
||||
recordType: "session",
|
||||
schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION,
|
||||
sessionId: header.id,
|
||||
version: header.version,
|
||||
createdAt: maybeString(header.timestamp),
|
||||
modifiedAt: modifiedAtToString(options.modifiedAt),
|
||||
parentSessionHash: header.parentSession ? hash(header.parentSession) : undefined,
|
||||
cwd: maybeString(header.cwd),
|
||||
cwdHash: header.cwd ? hash(header.cwd) : undefined,
|
||||
hasCwd: Boolean(header.cwd),
|
||||
});
|
||||
}
|
||||
|
||||
function projectUsageForAnalytics(usage: unknown): SessionAnalyticsUsage | undefined {
|
||||
if (!isRecord(usage)) return undefined;
|
||||
const cost = isRecord(usage.cost) ? usage.cost : undefined;
|
||||
return {
|
||||
input: asNumber(usage.input),
|
||||
output: asNumber(usage.output),
|
||||
cacheRead: asNumber(usage.cacheRead),
|
||||
cacheWrite: asNumber(usage.cacheWrite),
|
||||
totalTokens: asNumber(usage.totalTokens),
|
||||
costInput: asNumber(cost?.input),
|
||||
costOutput: asNumber(cost?.output),
|
||||
costCacheRead: asNumber(cost?.cacheRead),
|
||||
costCacheWrite: asNumber(cost?.cacheWrite),
|
||||
costTotal: asNumber(cost?.total),
|
||||
};
|
||||
}
|
||||
|
||||
function projectContentStatsForAnalytics(content: unknown): SessionAnalyticsContentStats {
|
||||
const stats: SessionAnalyticsContentStats = {
|
||||
stringContent: false,
|
||||
textBlocks: 0,
|
||||
imageBlocks: 0,
|
||||
thinkingBlocks: 0,
|
||||
redactedThinkingBlocks: 0,
|
||||
toolCallBlocks: 0,
|
||||
otherBlocks: 0,
|
||||
};
|
||||
|
||||
if (typeof content === "string") {
|
||||
stats.stringContent = true;
|
||||
if (content.length > 0) stats.textBlocks = 1;
|
||||
return stats;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
stats.otherBlocks = 1;
|
||||
return stats;
|
||||
}
|
||||
|
||||
for (const block of content) {
|
||||
if (!isRecord(block)) {
|
||||
stats.otherBlocks++;
|
||||
continue;
|
||||
}
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
stats.textBlocks++;
|
||||
break;
|
||||
case "image":
|
||||
stats.imageBlocks++;
|
||||
break;
|
||||
case "thinking":
|
||||
stats.thinkingBlocks++;
|
||||
if (block.redacted === true) stats.redactedThinkingBlocks++;
|
||||
break;
|
||||
case "toolCall":
|
||||
stats.toolCallBlocks++;
|
||||
break;
|
||||
default:
|
||||
stats.otherBlocks++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
function createEntryBase(sessionId: string, entry: SessionEntry): SessionAnalyticsEntryRecord {
|
||||
return {
|
||||
recordType: "entry",
|
||||
schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION,
|
||||
sessionId,
|
||||
entryId: entry.id,
|
||||
parentEntryId: entry.parentId,
|
||||
entryType: entry.type,
|
||||
timestamp: entry.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function projectMessageEntryForAnalytics(sessionId: string, entry: Extract<SessionEntry, { type: "message" }>) {
|
||||
const base = createEntryBase(sessionId, entry);
|
||||
const message = isRecord(entry.message) ? entry.message : undefined;
|
||||
const role = maybeString(message?.role) ?? "unknown";
|
||||
|
||||
if (role === "assistant") {
|
||||
return cleanRecord({
|
||||
...base,
|
||||
role,
|
||||
api: maybeString(message?.api),
|
||||
provider: maybeString(message?.provider),
|
||||
model: maybeString(message?.model),
|
||||
responseModel: maybeString(message?.responseModel),
|
||||
stopReason: maybeString(message?.stopReason),
|
||||
hasError:
|
||||
Boolean(message?.errorMessage) || message?.stopReason === "error" || message?.stopReason === "aborted",
|
||||
usage: projectUsageForAnalytics(message?.usage),
|
||||
contentStats: projectContentStatsForAnalytics(message?.content),
|
||||
});
|
||||
}
|
||||
|
||||
if (role === "user") return { ...base, role, contentStats: projectContentStatsForAnalytics(message?.content) };
|
||||
|
||||
if (role === "toolResult") {
|
||||
return {
|
||||
...base,
|
||||
role,
|
||||
isError: message?.isError === true,
|
||||
contentStats: projectContentStatsForAnalytics(message?.content),
|
||||
};
|
||||
}
|
||||
|
||||
if (role === "bashExecution") {
|
||||
return cleanRecord({
|
||||
...base,
|
||||
role,
|
||||
exitCode: maybeNumber(message?.exitCode),
|
||||
cancelled: message?.cancelled === true,
|
||||
truncated: message?.truncated === true,
|
||||
excludeFromContext: message?.excludeFromContext === true,
|
||||
});
|
||||
}
|
||||
|
||||
if (role === "custom") {
|
||||
return cleanRecord({
|
||||
...base,
|
||||
role,
|
||||
customType: maybeString(message?.customType),
|
||||
display: typeof message?.display === "boolean" ? message.display : undefined,
|
||||
contentStats: projectContentStatsForAnalytics(message?.content),
|
||||
});
|
||||
}
|
||||
|
||||
return { ...base, role };
|
||||
}
|
||||
|
||||
export function projectSessionEntryForAnalytics(sessionId: string, entry: SessionEntry): SessionAnalyticsEntryRecord {
|
||||
switch (entry.type) {
|
||||
case "message":
|
||||
return projectMessageEntryForAnalytics(sessionId, entry);
|
||||
case "model_change":
|
||||
return { ...createEntryBase(sessionId, entry), provider: entry.provider, modelId: entry.modelId };
|
||||
case "thinking_level_change":
|
||||
return { ...createEntryBase(sessionId, entry), thinkingLevel: entry.thinkingLevel };
|
||||
case "compaction":
|
||||
return cleanRecord({
|
||||
...createEntryBase(sessionId, entry),
|
||||
firstKeptEntryId: entry.firstKeptEntryId,
|
||||
tokensBefore: entry.tokensBefore,
|
||||
fromHook: entry.fromHook,
|
||||
hasDetails: entry.details !== undefined,
|
||||
});
|
||||
case "branch_summary":
|
||||
return cleanRecord({
|
||||
...createEntryBase(sessionId, entry),
|
||||
fromId: entry.fromId,
|
||||
fromHook: entry.fromHook,
|
||||
hasDetails: entry.details !== undefined,
|
||||
});
|
||||
case "custom":
|
||||
return cleanRecord({
|
||||
...createEntryBase(sessionId, entry),
|
||||
customType: entry.customType,
|
||||
hasData: entry.data !== undefined,
|
||||
});
|
||||
case "custom_message":
|
||||
return cleanRecord({
|
||||
...createEntryBase(sessionId, entry),
|
||||
customType: entry.customType,
|
||||
display: entry.display,
|
||||
hasDetails: entry.details !== undefined,
|
||||
contentStats: projectContentStatsForAnalytics(entry.content),
|
||||
});
|
||||
case "label":
|
||||
return cleanRecord({
|
||||
...createEntryBase(sessionId, entry),
|
||||
targetId: entry.targetId,
|
||||
hasLabel: typeof entry.label === "string" && entry.label.length > 0,
|
||||
});
|
||||
case "session_info":
|
||||
return { ...createEntryBase(sessionId, entry), hasName: Boolean(entry.name?.trim()) };
|
||||
default:
|
||||
return createEntryBase(sessionId, entry);
|
||||
}
|
||||
}
|
||||
|
||||
export function projectSessionForAnalytics(
|
||||
header: SessionHeader,
|
||||
entries: SessionEntry[],
|
||||
options: ProjectSessionAnalyticsOptions = {},
|
||||
): SessionAnalyticsRecord[] {
|
||||
const session = projectSessionHeaderForAnalytics(header, options);
|
||||
return [session, ...entries.map((entry) => projectSessionEntryForAnalytics(session.sessionId, entry))];
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
projectSessionForAnalytics,
|
||||
SESSION_ANALYTICS_SCHEMA_VERSION,
|
||||
type SessionAnalyticsRecord,
|
||||
} from "../src/core/session-analytics.ts";
|
||||
import type { SessionEntry, SessionHeader } from "../src/core/session-manager.ts";
|
||||
|
||||
const header: SessionHeader = {
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "session-1",
|
||||
timestamp: "2026-01-02T03:04:05.000Z",
|
||||
cwd: "/tmp/project",
|
||||
parentSession: "/tmp/parent.jsonl",
|
||||
};
|
||||
|
||||
const entries: SessionEntry[] = [
|
||||
{
|
||||
type: "model_change",
|
||||
id: "model-1",
|
||||
parentId: null,
|
||||
timestamp: "2026-01-02T03:04:06.000Z",
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "user-1",
|
||||
parentId: "model-1",
|
||||
timestamp: "2026-01-02T03:04:07.000Z",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "secret user prompt" },
|
||||
{ type: "image", data: "secret-image-data", mimeType: "image/png" },
|
||||
],
|
||||
timestamp: 1767323047000,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "assistant-1",
|
||||
parentId: "user-1",
|
||||
timestamp: "2026-01-02T03:04:08.000Z",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "secret assistant answer" },
|
||||
{ type: "thinking", thinking: "secret reasoning" },
|
||||
{ type: "thinking", thinking: "", thinkingSignature: "secret-signature", redacted: true },
|
||||
{ type: "toolCall", id: "call-1", name: "read", arguments: { path: "secret/path.ts" } },
|
||||
],
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
responseModel: "claude-sonnet-4-5-20260101",
|
||||
responseId: "secret-response-id",
|
||||
usage: {
|
||||
input: 100,
|
||||
output: 20,
|
||||
cacheRead: 30,
|
||||
cacheWrite: 40,
|
||||
totalTokens: 190,
|
||||
cost: {
|
||||
input: 0.1,
|
||||
output: 0.2,
|
||||
cacheRead: 0.03,
|
||||
cacheWrite: 0.04,
|
||||
total: 0.37,
|
||||
},
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: 1767323048000,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "tool-result-1",
|
||||
parentId: "assistant-1",
|
||||
timestamp: "2026-01-02T03:04:09.000Z",
|
||||
message: {
|
||||
role: "toolResult",
|
||||
toolCallId: "call-1",
|
||||
toolName: "read",
|
||||
content: [{ type: "text", text: "secret tool result" }],
|
||||
isError: true,
|
||||
timestamp: 1767323049000,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "compaction",
|
||||
id: "compaction-1",
|
||||
parentId: "tool-result-1",
|
||||
timestamp: "2026-01-02T03:04:10.000Z",
|
||||
summary: "secret compaction summary",
|
||||
firstKeptEntryId: "user-1",
|
||||
tokensBefore: 1234,
|
||||
details: { secret: "compaction details" },
|
||||
fromHook: true,
|
||||
},
|
||||
{
|
||||
type: "label",
|
||||
id: "label-1",
|
||||
parentId: "compaction-1",
|
||||
timestamp: "2026-01-02T03:04:11.000Z",
|
||||
targetId: "assistant-1",
|
||||
label: "secret label",
|
||||
},
|
||||
{
|
||||
type: "session_info",
|
||||
id: "session-info-1",
|
||||
parentId: "label-1",
|
||||
timestamp: "2026-01-02T03:04:12.000Z",
|
||||
name: "secret session name",
|
||||
},
|
||||
];
|
||||
|
||||
describe("projectSessionForAnalytics", () => {
|
||||
it("projects a complete session into ordered analytics records", () => {
|
||||
const records = projectSessionForAnalytics(header, entries, {
|
||||
modifiedAt: "2026-01-03T04:05:06.000Z",
|
||||
hashString: (value) => `hashed:${value}`,
|
||||
});
|
||||
|
||||
expect(records).toEqual([
|
||||
{
|
||||
recordType: "session",
|
||||
schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION,
|
||||
sessionId: "session-1",
|
||||
version: 3,
|
||||
createdAt: "2026-01-02T03:04:05.000Z",
|
||||
modifiedAt: "2026-01-03T04:05:06.000Z",
|
||||
parentSessionHash: "hashed:/tmp/parent.jsonl",
|
||||
cwd: "/tmp/project",
|
||||
cwdHash: "hashed:/tmp/project",
|
||||
hasCwd: true,
|
||||
},
|
||||
{
|
||||
recordType: "entry",
|
||||
schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION,
|
||||
sessionId: "session-1",
|
||||
entryId: "model-1",
|
||||
parentEntryId: null,
|
||||
entryType: "model_change",
|
||||
timestamp: "2026-01-02T03:04:06.000Z",
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
},
|
||||
{
|
||||
recordType: "entry",
|
||||
schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION,
|
||||
sessionId: "session-1",
|
||||
entryId: "user-1",
|
||||
parentEntryId: "model-1",
|
||||
entryType: "message",
|
||||
timestamp: "2026-01-02T03:04:07.000Z",
|
||||
role: "user",
|
||||
contentStats: {
|
||||
stringContent: false,
|
||||
textBlocks: 1,
|
||||
imageBlocks: 1,
|
||||
thinkingBlocks: 0,
|
||||
redactedThinkingBlocks: 0,
|
||||
toolCallBlocks: 0,
|
||||
otherBlocks: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
recordType: "entry",
|
||||
schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION,
|
||||
sessionId: "session-1",
|
||||
entryId: "assistant-1",
|
||||
parentEntryId: "user-1",
|
||||
entryType: "message",
|
||||
timestamp: "2026-01-02T03:04:08.000Z",
|
||||
role: "assistant",
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: "claude-sonnet-4-5",
|
||||
responseModel: "claude-sonnet-4-5-20260101",
|
||||
stopReason: "toolUse",
|
||||
hasError: false,
|
||||
usage: {
|
||||
input: 100,
|
||||
output: 20,
|
||||
cacheRead: 30,
|
||||
cacheWrite: 40,
|
||||
totalTokens: 190,
|
||||
costInput: 0.1,
|
||||
costOutput: 0.2,
|
||||
costCacheRead: 0.03,
|
||||
costCacheWrite: 0.04,
|
||||
costTotal: 0.37,
|
||||
},
|
||||
contentStats: {
|
||||
stringContent: false,
|
||||
textBlocks: 1,
|
||||
imageBlocks: 0,
|
||||
thinkingBlocks: 2,
|
||||
redactedThinkingBlocks: 1,
|
||||
toolCallBlocks: 1,
|
||||
otherBlocks: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
recordType: "entry",
|
||||
schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION,
|
||||
sessionId: "session-1",
|
||||
entryId: "tool-result-1",
|
||||
parentEntryId: "assistant-1",
|
||||
entryType: "message",
|
||||
timestamp: "2026-01-02T03:04:09.000Z",
|
||||
role: "toolResult",
|
||||
isError: true,
|
||||
contentStats: {
|
||||
stringContent: false,
|
||||
textBlocks: 1,
|
||||
imageBlocks: 0,
|
||||
thinkingBlocks: 0,
|
||||
redactedThinkingBlocks: 0,
|
||||
toolCallBlocks: 0,
|
||||
otherBlocks: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
recordType: "entry",
|
||||
schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION,
|
||||
sessionId: "session-1",
|
||||
entryId: "compaction-1",
|
||||
parentEntryId: "tool-result-1",
|
||||
entryType: "compaction",
|
||||
timestamp: "2026-01-02T03:04:10.000Z",
|
||||
firstKeptEntryId: "user-1",
|
||||
tokensBefore: 1234,
|
||||
fromHook: true,
|
||||
hasDetails: true,
|
||||
},
|
||||
{
|
||||
recordType: "entry",
|
||||
schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION,
|
||||
sessionId: "session-1",
|
||||
entryId: "label-1",
|
||||
parentEntryId: "compaction-1",
|
||||
entryType: "label",
|
||||
timestamp: "2026-01-02T03:04:11.000Z",
|
||||
targetId: "assistant-1",
|
||||
hasLabel: true,
|
||||
},
|
||||
{
|
||||
recordType: "entry",
|
||||
schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION,
|
||||
sessionId: "session-1",
|
||||
entryId: "session-info-1",
|
||||
parentEntryId: "label-1",
|
||||
entryType: "session_info",
|
||||
timestamp: "2026-01-02T03:04:12.000Z",
|
||||
hasName: true,
|
||||
},
|
||||
] satisfies SessionAnalyticsRecord[]);
|
||||
});
|
||||
|
||||
it("omits raw private payload fields", () => {
|
||||
const serialized = JSON.stringify(projectSessionForAnalytics(header, entries));
|
||||
|
||||
expect(serialized).not.toContain("secret user prompt");
|
||||
expect(serialized).not.toContain("secret assistant answer");
|
||||
expect(serialized).not.toContain("secret reasoning");
|
||||
expect(serialized).not.toContain("secret-image-data");
|
||||
expect(serialized).not.toContain("secret/path.ts");
|
||||
expect(serialized).not.toContain("secret tool result");
|
||||
expect(serialized).not.toContain("secret compaction summary");
|
||||
expect(serialized).not.toContain("compaction details");
|
||||
expect(serialized).not.toContain("secret label");
|
||||
expect(serialized).not.toContain("secret session name");
|
||||
expect(serialized).not.toContain("secret-response-id");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user