mirror of
https://github.com/earendil-works/pi.git
synced 2026-06-18 15:54:04 +08:00
feat(coding-agent): add session sync
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { buildSessionAnalyticsUpload } from "../src/core/session-analytics-reader.ts";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createTempDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pi-session-analytics-reader-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function writeJsonl(path: string, lines: unknown[]): void {
|
||||
writeFileSync(path, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`);
|
||||
}
|
||||
|
||||
function sessionHeader(id: string, timestamp: string): unknown {
|
||||
return { type: "session", version: 3, id, timestamp, cwd: `/work/${id}` };
|
||||
}
|
||||
|
||||
function entry(id: string, timestamp: string): unknown {
|
||||
return { type: "model_change", id, parentId: null, timestamp, provider: "anthropic", modelId: "model" };
|
||||
}
|
||||
|
||||
describe("buildSessionAnalyticsUpload", () => {
|
||||
it("selects files by mtime and includes old records before the scan cutoff", async () => {
|
||||
const root = createTempDir();
|
||||
const project = join(root, "--project--");
|
||||
mkdirSync(project, { recursive: true });
|
||||
const oldFile = join(project, "old.jsonl");
|
||||
const changedFile = join(project, "changed.jsonl");
|
||||
writeJsonl(oldFile, [
|
||||
sessionHeader("old", "2026-01-01T00:00:00.000Z"),
|
||||
entry("old-entry", "2026-01-01T00:00:01.000Z"),
|
||||
]);
|
||||
writeJsonl(changedFile, [
|
||||
sessionHeader("changed", "2026-01-01T00:00:00.000Z"),
|
||||
entry("included-old-entry", "2026-01-01T00:00:01.000Z"),
|
||||
entry("future-entry", "2026-01-03T00:00:00.000Z"),
|
||||
]);
|
||||
utimesSync(oldFile, new Date("2026-01-01T00:10:00.000Z"), new Date("2026-01-01T00:10:00.000Z"));
|
||||
utimesSync(changedFile, new Date("2026-01-02T00:10:00.000Z"), new Date("2026-01-02T00:10:00.000Z"));
|
||||
|
||||
const result = await buildSessionAnalyticsUpload({
|
||||
sessionsRoot: root,
|
||||
serverWatermark: "2026-01-02T00:00:00.000Z",
|
||||
scanCutoff: new Date("2026-01-02T12:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.filesScanned).toBe(1);
|
||||
expect(result.malformedFiles).toBe(0);
|
||||
expect(result.scanCutoff).toBe("2026-01-02T12:00:00.000Z");
|
||||
expect(
|
||||
result.records.map((record) => (record.recordType === "entry" ? record.entryId : record.sessionId)),
|
||||
).toEqual(["changed", "included-old-entry"]);
|
||||
});
|
||||
|
||||
it("skips malformed selected files", async () => {
|
||||
const root = createTempDir();
|
||||
const project = join(root, "--project--");
|
||||
mkdirSync(project, { recursive: true });
|
||||
const malformed = join(project, "malformed.jsonl");
|
||||
writeFileSync(malformed, `${JSON.stringify(sessionHeader("bad", "2026-01-01T00:00:00.000Z"))}\nnot json\n`);
|
||||
|
||||
const result = await buildSessionAnalyticsUpload({
|
||||
sessionsRoot: root,
|
||||
serverWatermark: null,
|
||||
scanCutoff: new Date("2026-01-02T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.records).toEqual([]);
|
||||
expect(result.filesScanned).toBe(1);
|
||||
expect(result.malformedFiles).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
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",
|
||||
},
|
||||
{
|
||||
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");
|
||||
expect(serialized).not.toContain("/tmp/project");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { basename, join, relative } from "path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
discoverSessionFiles,
|
||||
discoverSessions,
|
||||
type SessionDiscoveryProgress,
|
||||
} from "../src/core/session-discovery.ts";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createTempDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pi-session-discovery-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function writeJsonl(path: string, lines: unknown[]): void {
|
||||
writeFileSync(path, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`);
|
||||
}
|
||||
|
||||
describe("session discovery", () => {
|
||||
it("finds session jsonl files recursively", async () => {
|
||||
const root = createTempDir();
|
||||
const projectA = join(root, "--project-a--");
|
||||
const projectB = join(root, "--project-b--", "nested");
|
||||
mkdirSync(projectA, { recursive: true });
|
||||
mkdirSync(projectB, { recursive: true });
|
||||
writeJsonl(join(projectA, "a.jsonl"), [
|
||||
{ type: "session", version: 3, id: "a", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/a" },
|
||||
]);
|
||||
writeJsonl(join(projectB, "b.jsonl"), [
|
||||
{ type: "session", version: 3, id: "b", timestamp: "2026-01-02T00:00:00.000Z", cwd: "/b" },
|
||||
]);
|
||||
writeFileSync(join(projectA, "notes.txt"), "not a session");
|
||||
|
||||
const files = await discoverSessionFiles({ sessionsRoot: root });
|
||||
|
||||
expect(files.map((file) => relative(root, file))).toEqual([
|
||||
join("--project-a--", "a.jsonl"),
|
||||
join("--project-b--", "nested", "b.jsonl"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns metadata for valid sessions and skips invalid jsonl files", async () => {
|
||||
const root = createTempDir();
|
||||
const projectA = join(root, "--project-a--");
|
||||
const projectB = join(root, "--project-b--");
|
||||
mkdirSync(projectA, { recursive: true });
|
||||
mkdirSync(projectB, { recursive: true });
|
||||
writeJsonl(join(projectA, "a.jsonl"), [
|
||||
{
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "session-a",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
cwd: "/work/a",
|
||||
parentSession: "/parent/session.jsonl",
|
||||
},
|
||||
]);
|
||||
writeJsonl(join(projectB, "b.jsonl"), [
|
||||
{ type: "session", version: 3, id: "session-b", timestamp: "2026-01-02T00:00:00.000Z", cwd: "/work/b" },
|
||||
]);
|
||||
writeFileSync(join(projectB, "invalid.jsonl"), "not json\n");
|
||||
|
||||
const progress: SessionDiscoveryProgress[] = [];
|
||||
const sessions = await discoverSessions({ sessionsRoot: root, onProgress: (update) => progress.push(update) });
|
||||
|
||||
expect(sessions.map((session) => session.sessionId)).toEqual(["session-a", "session-b"]);
|
||||
expect(sessions[0]).toMatchObject({
|
||||
path: join(projectA, "a.jsonl"),
|
||||
relativePath: join("--project-a--", "a.jsonl"),
|
||||
sessionDir: projectA,
|
||||
sessionDirName: basename(projectA),
|
||||
sessionId: "session-a",
|
||||
cwd: "/work/a",
|
||||
header: {
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "session-a",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
cwd: "/work/a",
|
||||
parentSession: "/parent/session.jsonl",
|
||||
},
|
||||
});
|
||||
expect(sessions[0].createdAt?.toISOString()).toBe("2026-01-01T00:00:00.000Z");
|
||||
expect(sessions[0].modifiedAt).toBeInstanceOf(Date);
|
||||
expect(sessions[0].sizeBytes).toBeGreaterThan(0);
|
||||
expect(progress.at(-1)).toMatchObject({ phase: "read", foundFiles: 3, processedFiles: 3, sessions: 2 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getSessionSyncWatermark,
|
||||
refreshSessionSyncAccessToken,
|
||||
SessionSyncApiError,
|
||||
startSessionSyncDeviceFlow,
|
||||
uploadSessionAnalytics,
|
||||
} from "../src/core/session-sync-api.ts";
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
describe("session sync api", () => {
|
||||
it("starts the OAuth device flow with the session sync scope", async () => {
|
||||
let request: Request | undefined;
|
||||
const fetchMock: typeof fetch = async (input, init) => {
|
||||
request = new Request(input, init);
|
||||
return jsonResponse({
|
||||
device_code: "pigd_1",
|
||||
user_code: "ABCD-EFGH",
|
||||
verification_uri: "https://pi.dev/pair",
|
||||
verification_uri_complete: "https://pi.dev/pair?code=ABCD-EFGH",
|
||||
expires_in: 300,
|
||||
interval: 2,
|
||||
});
|
||||
};
|
||||
|
||||
const response = await startSessionSyncDeviceFlow("00000000-0000-4000-8000-000000000000", {
|
||||
baseUrl: "https://example.test/",
|
||||
fetch: fetchMock,
|
||||
});
|
||||
|
||||
expect(response.device_code).toBe("pigd_1");
|
||||
expect(request?.url).toBe("https://example.test/api/oauth/device");
|
||||
expect(request?.method).toBe("POST");
|
||||
const body = new URLSearchParams(await request?.text());
|
||||
expect(body.get("client_id")).toBe("pi-coding-agent");
|
||||
expect(body.get("scope")).toBe("session_sync offline_access");
|
||||
expect(body.get("device_id")).toBe("00000000-0000-4000-8000-000000000000");
|
||||
});
|
||||
|
||||
it("refreshes tokens and reads watermarks", async () => {
|
||||
const urls: string[] = [];
|
||||
const fetchMock: typeof fetch = async (input, init) => {
|
||||
const request = new Request(input, init);
|
||||
urls.push(request.url);
|
||||
if (request.url.endsWith("/api/oauth/token")) {
|
||||
return jsonResponse({
|
||||
token_type: "Bearer",
|
||||
access_token: "access-2",
|
||||
refresh_token: "refresh-2",
|
||||
expires_in: 86400,
|
||||
scope: "session_sync offline_access",
|
||||
});
|
||||
}
|
||||
expect(request.headers.get("Authorization")).toBe("Bearer access-2");
|
||||
return jsonResponse({ ok: true, watermark: "2026-01-01T00:00:00.000Z" });
|
||||
};
|
||||
|
||||
const token = await refreshSessionSyncAccessToken("refresh-1", {
|
||||
baseUrl: "https://example.test",
|
||||
fetch: fetchMock,
|
||||
});
|
||||
const watermark = await getSessionSyncWatermark(token.access_token, "device-1", {
|
||||
baseUrl: "https://example.test",
|
||||
fetch: fetchMock,
|
||||
});
|
||||
|
||||
expect(token.refresh_token).toBe("refresh-2");
|
||||
expect(watermark.watermark).toBe("2026-01-01T00:00:00.000Z");
|
||||
expect(urls).toEqual([
|
||||
"https://example.test/api/oauth/token",
|
||||
"https://example.test/analytics/sessions/device-1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uploads compressed NDJSON with sync headers and surfaces API errors", async () => {
|
||||
let request: Request | undefined;
|
||||
const fetchMock: typeof fetch = async (input, init) => {
|
||||
request = new Request(input, init);
|
||||
return jsonResponse(
|
||||
{
|
||||
ok: true,
|
||||
records_received: 1,
|
||||
first_record_timestamp: "2026-01-01T00:00:00.000Z",
|
||||
last_record_timestamp: "2026-01-01T00:00:00.000Z",
|
||||
received_bytes: 10,
|
||||
watermark: "2026-01-02T00:00:00.000Z",
|
||||
},
|
||||
201,
|
||||
);
|
||||
};
|
||||
|
||||
const response = await uploadSessionAnalytics({
|
||||
baseUrl: "https://example.test",
|
||||
fetch: fetchMock,
|
||||
accessToken: "access-1",
|
||||
deviceId: "device-1",
|
||||
watermark: "2026-01-02T00:00:00.000Z",
|
||||
idempotencyKey: "retry-key",
|
||||
body: Buffer.from("payload"),
|
||||
contentEncoding: "zstd",
|
||||
});
|
||||
|
||||
expect(response.records_received).toBe(1);
|
||||
expect(request?.headers.get("Authorization")).toBe("Bearer access-1");
|
||||
expect(request?.headers.get("Content-Type")).toBe("application/x-ndjson");
|
||||
expect(request?.headers.get("Content-Encoding")).toBe("zstd");
|
||||
expect(request?.headers.get("Pi-Sync-Watermark")).toBe("2026-01-02T00:00:00.000Z");
|
||||
expect(request?.headers.get("Idempotency-Key")).toBe("retry-key");
|
||||
|
||||
const failingFetch: typeof fetch = async () =>
|
||||
jsonResponse({ ok: false, error: "invalid_payload", description: "bad line" }, 400);
|
||||
await expect(
|
||||
uploadSessionAnalytics({
|
||||
baseUrl: "https://example.test",
|
||||
fetch: failingFetch,
|
||||
accessToken: "access-1",
|
||||
deviceId: "device-1",
|
||||
watermark: "2026-01-02T00:00:00.000Z",
|
||||
idempotencyKey: "retry-key",
|
||||
body: Buffer.from("payload"),
|
||||
contentEncoding: "zstd",
|
||||
}),
|
||||
).rejects.toMatchObject(new SessionSyncApiError(400, "invalid_payload", "bad line"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { promisify } from "node:util";
|
||||
import { zstdDecompress } from "node:zlib";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SessionAnalyticsRecord } from "../src/core/session-analytics.ts";
|
||||
import {
|
||||
buildSessionSyncPayloads,
|
||||
serializeSessionAnalyticsNdjson,
|
||||
sortSessionAnalyticsRecords,
|
||||
} from "../src/core/session-sync-payload.ts";
|
||||
|
||||
const zstdDecompressAsync = promisify(zstdDecompress);
|
||||
|
||||
function session(id: string, createdAt: string): SessionAnalyticsRecord {
|
||||
return { recordType: "session", schemaVersion: 1, sessionId: id, createdAt };
|
||||
}
|
||||
|
||||
function entry(id: string, timestamp: string): SessionAnalyticsRecord {
|
||||
return {
|
||||
recordType: "entry",
|
||||
schemaVersion: 1,
|
||||
sessionId: "session-1",
|
||||
entryId: id,
|
||||
parentEntryId: null,
|
||||
entryType: "model_change",
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
describe("session sync payloads", () => {
|
||||
it("sorts records oldest-first with sessions before entries at the same timestamp", () => {
|
||||
const records = [
|
||||
entry("entry-b", "2026-01-02T00:00:00.000Z"),
|
||||
session("session-1", "2026-01-01T00:00:00.000Z"),
|
||||
entry("entry-a", "2026-01-01T00:00:00.000Z"),
|
||||
];
|
||||
|
||||
expect(sortSessionAnalyticsRecords(records)).toEqual([
|
||||
session("session-1", "2026-01-01T00:00:00.000Z"),
|
||||
entry("entry-a", "2026-01-01T00:00:00.000Z"),
|
||||
entry("entry-b", "2026-01-02T00:00:00.000Z"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("globally sorts, serializes, and zstd-compresses NDJSON", async () => {
|
||||
const records = [entry("entry-1", "2026-01-01T00:00:01.000Z"), session("session-1", "2026-01-01T00:00:00.000Z")];
|
||||
const [payload] = await buildSessionSyncPayloads({
|
||||
records,
|
||||
scanCutoff: "2026-01-02T00:00:00.000Z",
|
||||
serverWatermark: null,
|
||||
});
|
||||
|
||||
expect(payload.contentEncoding).toBe("zstd");
|
||||
expect(payload.watermark).toBe("2026-01-02T00:00:00.000Z");
|
||||
expect((await zstdDecompressAsync(payload.body)).toString("utf8")).toBe(
|
||||
serializeSessionAnalyticsNdjson(sortSessionAnalyticsRecords(records)).toString("utf8"),
|
||||
);
|
||||
});
|
||||
|
||||
it("globally sorts split payloads and uses scanCutoff only for the final batch watermark", async () => {
|
||||
const records = [
|
||||
entry("new", "2026-01-03T00:00:00.000Z"),
|
||||
entry("same-b", "2026-01-02T00:00:00.000Z"),
|
||||
entry("old", "2026-01-01T00:00:00.000Z"),
|
||||
entry("same-a", "2026-01-02T00:00:00.000Z"),
|
||||
];
|
||||
const payloads = await buildSessionSyncPayloads({
|
||||
records,
|
||||
scanCutoff: "2026-01-04T00:00:00.000Z",
|
||||
serverWatermark: "2026-01-01T12:00:00.000Z",
|
||||
maxCompressedBytes: 400,
|
||||
maxDecompressedBytes: 400,
|
||||
compress: async (input) => input,
|
||||
});
|
||||
|
||||
expect(
|
||||
payloads.map((payload) =>
|
||||
payload.records.map((record) => (record.recordType === "entry" ? record.entryId : record.sessionId)),
|
||||
),
|
||||
).toEqual([["old"], ["same-a", "same-b"], ["new"]]);
|
||||
expect(payloads.map((payload) => payload.watermark)).toEqual([
|
||||
"2026-01-01T12:00:00.000Z",
|
||||
"2026-01-02T00:00:00.000Z",
|
||||
"2026-01-04T00:00:00.000Z",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getStableSessionSyncDeviceId,
|
||||
loadSessionSyncState,
|
||||
saveSessionSyncState,
|
||||
withSessionSyncLock,
|
||||
} from "../src/core/session-sync-state.ts";
|
||||
import { SettingsManager } from "../src/core/settings-manager.ts";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createTempDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pi-session-sync-state-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("session sync state", () => {
|
||||
it("loads and saves sync state", async () => {
|
||||
const agentDir = createTempDir();
|
||||
await saveSessionSyncState({ refreshToken: "refresh-1", lastAttemptAt: "2026-01-01T00:00:00.000Z" }, agentDir);
|
||||
|
||||
expect(await loadSessionSyncState(agentDir)).toEqual({
|
||||
refreshToken: "refresh-1",
|
||||
lastAttemptAt: "2026-01-01T00:00:00.000Z",
|
||||
lastSuccessAt: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("stores a stable device id in telemetry settings", () => {
|
||||
const settings = SettingsManager.inMemory();
|
||||
const first = getStableSessionSyncDeviceId(settings);
|
||||
const second = getStableSessionSyncDeviceId(settings);
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(first).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(settings.getGlobalSettings().telemetry?.sessionSyncDeviceId).toBe(first);
|
||||
});
|
||||
|
||||
it("returns already_running when the sync lock is held", async () => {
|
||||
const agentDir = createTempDir();
|
||||
const result = await withSessionSyncLock(
|
||||
async () => withSessionSyncLock(async () => "inner", agentDir),
|
||||
agentDir,
|
||||
);
|
||||
|
||||
expect(result).toEqual({ status: "acquired", result: { status: "already_running" } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { syncSessionAnalytics } from "../src/core/session-sync.ts";
|
||||
import { loadSessionSyncState, saveSessionSyncState } from "../src/core/session-sync-state.ts";
|
||||
import { SettingsManager } from "../src/core/settings-manager.ts";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createTempDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pi-session-sync-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
|
||||
function writeSessionFile(sessionsRoot: string): void {
|
||||
const sessionDir = join(sessionsRoot, "default");
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(sessionDir, "session-1.jsonl"),
|
||||
`${[
|
||||
{
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "session-1",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
cwd: "/tmp",
|
||||
},
|
||||
{
|
||||
type: "model_change",
|
||||
id: "entry-1",
|
||||
parentId: null,
|
||||
timestamp: "2026-01-01T00:00:01.000Z",
|
||||
provider: "openai",
|
||||
modelId: "gpt-4.1",
|
||||
},
|
||||
]
|
||||
.map((record) => JSON.stringify(record))
|
||||
.join("\n")}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
describe("syncSessionAnalytics", () => {
|
||||
it("returns not_authenticated after recording lastAttemptAt", async () => {
|
||||
const agentDir = createTempDir();
|
||||
const result = await syncSessionAnalytics({
|
||||
agentDir,
|
||||
settingsManager: SettingsManager.inMemory(),
|
||||
now: new Date("2026-01-01T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result).toEqual({ status: "not_authenticated" });
|
||||
expect(await loadSessionSyncState(agentDir)).toMatchObject({ lastAttemptAt: "2026-01-01T00:00:00.000Z" });
|
||||
});
|
||||
|
||||
it("updates lastAttemptAt on no_changes", async () => {
|
||||
const agentDir = createTempDir();
|
||||
const sessionsRoot = createTempDir();
|
||||
await saveSessionSyncState({ refreshToken: "refresh-1" }, agentDir);
|
||||
const fetchMock: typeof fetch = async (input, init) => {
|
||||
const request = new Request(input, init);
|
||||
if (request.url.endsWith("/api/oauth/token")) {
|
||||
return jsonResponse({
|
||||
token_type: "Bearer",
|
||||
access_token: "access-1",
|
||||
refresh_token: "refresh-2",
|
||||
expires_in: 86400,
|
||||
scope: "session_sync offline_access",
|
||||
});
|
||||
}
|
||||
return jsonResponse({ ok: true, watermark: null });
|
||||
};
|
||||
|
||||
const result = await syncSessionAnalytics({
|
||||
agentDir,
|
||||
sessionsRoot,
|
||||
settingsManager: SettingsManager.inMemory(),
|
||||
fetch: fetchMock,
|
||||
now: new Date("2026-01-02T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ status: "no_changes", filesScanned: 0 });
|
||||
expect(await loadSessionSyncState(agentDir)).toMatchObject({
|
||||
refreshToken: "refresh-2",
|
||||
lastAttemptAt: "2026-01-02T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("uploads with an idempotency key without persisting payload files", async () => {
|
||||
const agentDir = createTempDir();
|
||||
const sessionsRoot = createTempDir();
|
||||
writeSessionFile(sessionsRoot);
|
||||
await saveSessionSyncState({ refreshToken: "refresh-1" }, agentDir);
|
||||
const idempotencyKeys: string[] = [];
|
||||
const fetchMock: typeof fetch = async (input, init) => {
|
||||
const request = new Request(input, init);
|
||||
if (request.url.endsWith("/api/oauth/token")) {
|
||||
return jsonResponse({
|
||||
token_type: "Bearer",
|
||||
access_token: "access-1",
|
||||
refresh_token: "refresh-2",
|
||||
expires_in: 86400,
|
||||
scope: "session_sync offline_access",
|
||||
});
|
||||
}
|
||||
if (request.method === "GET") return jsonResponse({ ok: true, watermark: null });
|
||||
idempotencyKeys.push(request.headers.get("Idempotency-Key") ?? "");
|
||||
expect((await request.arrayBuffer()).byteLength).toBeGreaterThan(0);
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
records_received: 2,
|
||||
first_record_timestamp: "2026-01-01T00:00:00.000Z",
|
||||
last_record_timestamp: "2026-01-01T00:00:01.000Z",
|
||||
received_bytes: 21,
|
||||
watermark: "2026-01-02T00:00:00.000Z",
|
||||
});
|
||||
};
|
||||
|
||||
const result = await syncSessionAnalytics({
|
||||
agentDir,
|
||||
sessionsRoot,
|
||||
settingsManager: SettingsManager.inMemory(),
|
||||
fetch: fetchMock,
|
||||
now: new Date("2026-01-03T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "uploaded",
|
||||
recordsSent: 2,
|
||||
watermark: "2026-01-02T00:00:00.000Z",
|
||||
});
|
||||
expect(idempotencyKeys).toHaveLength(1);
|
||||
expect(idempotencyKeys[0]).toMatch(/^[0-9a-f-]{36}$/);
|
||||
expect(existsSync(join(agentDir, "session-sync-payloads"))).toBe(false);
|
||||
expect(await loadSessionSyncState(agentDir)).toMatchObject({
|
||||
refreshToken: "refresh-2",
|
||||
lastSuccessAt: "2026-01-03T00:00:00.000Z",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getVisibleBuiltinSlashCommands, isSessionSyncFeatureEnabled } from "../src/core/slash-commands.ts";
|
||||
|
||||
function visibleCommandNames(sessionSyncEnv?: string): string[] {
|
||||
return getVisibleBuiltinSlashCommands(sessionSyncEnv).map((command) => command.name);
|
||||
}
|
||||
|
||||
describe("slash commands", () => {
|
||||
it("hides session sync unless the early access flag is set", () => {
|
||||
expect(isSessionSyncFeatureEnabled(undefined)).toBe(false);
|
||||
expect(isSessionSyncFeatureEnabled("true")).toBe(false);
|
||||
expect(isSessionSyncFeatureEnabled("1")).toBe(true);
|
||||
expect(visibleCommandNames("")).not.toContain("session-sync");
|
||||
expect(visibleCommandNames("1")).toContain("session-sync");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user