feat(coding-agent): add session sync

This commit is contained in:
Vegard Stikbakke
2026-06-04 09:41:42 +02:00
parent dc7b547f62
commit 73dc5ad043
21 changed files with 2644 additions and 3 deletions
@@ -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");
});
});