Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/helpers/session-context-copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,12 +196,15 @@ export async function readCopilotSession(
path: eventsFile,
};

const raw = await readTextIfExists(eventsFile);
// A file that exists but cannot be read rejects rather than yielding null.
const raw = await readTextIfExists(eventsFile).catch(() => null);
if (raw === null) return noTranscript;

let rawEntries: z.infer<typeof CopilotEventsSchema>;
try {
rawEntries = CopilotEventsSchema.parse(Bun.JSONL.parse(raw));
const parsed = CopilotEventsSchema.safeParse(Bun.JSONL.parse(raw));
if (!parsed.success) return noTranscript;
rawEntries = parsed.data;
} catch {
return noTranscript;
}
Expand Down
20 changes: 16 additions & 4 deletions src/helpers/session-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,12 +283,18 @@ export async function readClaudeCodeSession(
}

const sessionFile = join(projectsDir, targetName);
const raw = await readTextIfExists(sessionFile);
// `.catch` as well as the null: readTextIfExists rejects for a file that
// exists but cannot be read, which is the case sessionReadFailure names.
const raw = await readTextIfExists(sessionFile).catch(() => null);
if (raw === null) return sessionReadFailure(sessionFile);

let entries: TranscriptEntry[];
try {
entries = z.array(TranscriptEntrySchema).parse(Bun.JSONL.parse(raw));
const parsed = z
.array(TranscriptEntrySchema)
.safeParse(Bun.JSONL.parse(raw));
if (!parsed.success) return sessionReadFailure(sessionFile);
entries = parsed.data;
} catch {
return sessionReadFailure(sessionFile);
}
Expand Down Expand Up @@ -433,12 +439,18 @@ export async function readCursorSession(
targetDir.name,
`${targetDir.name}.jsonl`
);
const raw = await readTextIfExists(sessionFile);
// `.catch` as well as the null: readTextIfExists rejects for a file that
// exists but cannot be read, which is the case sessionReadFailure names.
const raw = await readTextIfExists(sessionFile).catch(() => null);
if (raw === null) return sessionReadFailure(sessionFile);

let entries: TranscriptEntry[];
try {
entries = z.array(TranscriptEntrySchema).parse(Bun.JSONL.parse(raw));
const parsed = z
.array(TranscriptEntrySchema)
.safeParse(Bun.JSONL.parse(raw));
if (!parsed.success) return sessionReadFailure(sessionFile);
entries = parsed.data;
} catch {
return sessionReadFailure(sessionFile);
}
Expand Down
21 changes: 21 additions & 0 deletions tests/helpers/session-context-copilot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Copyright 2026 Archgate
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import {
chmodSync,
mkdirSync,
mkdtempSync,
rmSync,
Expand Down Expand Up @@ -60,6 +61,26 @@ describe("readCopilotSession", () => {
}
}

// events.jsonl exists but cannot be read: the reader reports "no transcript"
// rather than letting the rejection escape. Non-root POSIX only.
test.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"reports no transcript when events.jsonl cannot be read",
async () => {
const sessionId = `copilot-${uniqueId}-locked`;
makeSession(sessionId, projectRoot, ['{"type":"user.message"}']);
const file = join(stateDir, sessionId, "events.jsonl");
chmodSync(file, 0o000);
try {
const result = await readCopilotSession(projectRoot);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected a failure result");
expect(result.error).toContain("no conversation transcript");
} finally {
chmodSync(file, 0o600);
}
}
);

test("returns data for most recent session matching project", async () => {
const sessionId = `copilot-${uniqueId}-1`;
makeSession(sessionId, projectRoot, [
Expand Down
25 changes: 25 additions & 0 deletions tests/helpers/session-context-cursor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
mkdtempSync,
rmSync,
symlinkSync,
chmodSync,
writeFileSync,
} from "node:fs";
import * as os from "node:os";
Expand Down Expand Up @@ -65,6 +66,30 @@ describe("readCursorSession", () => {
writeFileSync(join(sessionDir, `${sessionId}.jsonl`), lines.join("\n"));
}

// Discovery finds the file, the read then fails — permission-denied here,
// a session removed mid-read in practice. Non-root POSIX only: chmod is a
// no-op on Windows and root ignores the mode bits.
test.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"reports a read failure when the transcript cannot be read",
async () => {
makeSession("session-locked", ['{"role":"user"}']);
const file = join(
transcriptsDir,
"session-locked",
"session-locked.jsonl"
);
chmodSync(file, 0o000);
try {
const result = await readCursorSession(projectRoot);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected a failure result");
expect(result.error).toBe("Failed to read session file");
} finally {
chmodSync(file, 0o600);
}
}
);

test("returns data for most recent session", async () => {
makeSession("session-abc", [
JSON.stringify({
Expand Down
31 changes: 30 additions & 1 deletion tests/helpers/session-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ import {
test,
type Mock,
} from "bun:test";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import {
chmodSync,
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from "node:fs";
import * as os from "node:os";
import { join } from "node:path";

Expand Down Expand Up @@ -171,6 +177,29 @@ describe("readClaudeCodeSession", () => {
);
}

// A session file can exist at discovery and be unreadable at the read:
// removed in between, or permission-denied. readTextIfExists rejects
// rather than yielding null there, which is the case sessionReadFailure
// names. Only reproducible as a non-root POSIX user — chmod is a no-op on
// Windows and root ignores the mode bits.
test.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"reports a read failure for a session file that cannot be read",
async () => {
const file = join(projectsDir, "session.jsonl");
writeFileSync(file, '{"type":"user"}');
chmodSync(file, 0o000);
try {
const result = await readClaudeCodeSession(projectRoot);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("expected a failure result");
expect(result.error).toBe("Failed to read session file");
} finally {
// Restore before afterEach's rmSync, which cannot remove it otherwise.
chmodSync(file, 0o600);
}
}
);

test("returns data with correct transcript when JSONL exists", async () => {
writeSession([
{ type: "user", message: { role: "user", content: "hello" } },
Expand Down