1import * as NodeServices from "@effect/platform-node/NodeServices";
2import * as NodeOS from "node:os";
3import { describe, expect, it } from "@effect/vitest";
4import {
5 type OrchestrationProjectShell,
6 ProjectId,
7 ProviderDriverKind,
8 ProviderInstanceId,
9 type ServerSettings as ContractServerSettings,
10} from "@t3tools/contracts";
11import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
12import * as Effect from "effect/Effect";
13import * as FileSystem from "effect/FileSystem";
14import * as Layer from "effect/Layer";
15import * as Option from "effect/Option";
16import * as Path from "effect/Path";
17import * as Schema from "effect/Schema";
18import * as Stream from "effect/Stream";
19import * as TestClock from "effect/testing/TestClock";
20
21import * as ServerConfig from "../config.ts";
22import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
23import * as ServerSettings from "../serverSettings.ts";
24import * as AgentSessionScanner from "./AgentSessionScanner.ts";
25
26const makeProjectShell = (workspaceRoot: string): OrchestrationProjectShell => ({
27 id: ProjectId.make("project-1"),
28 title: "Imported",
29 workspaceRoot,
30 defaultModelSelection: null,
31 scripts: [],
32 createdAt: "2026-01-01T00:00:00.000Z",
33 updatedAt: "2026-01-01T00:00:00.000Z",
34});
35
36const layerProjectStore = (importedWorkspaceRoots: ReadonlyArray<string>) =>
37 Layer.mock(ProjectStore.ProjectStoreV2)({
38 listShells: () =>
39 Effect.succeed(
40 importedWorkspaceRoots.map((workspaceRoot) => makeProjectShell(workspaceRoot)),
41 ),
42 });
43
44/**
45 * Run a scan against the given homes. Homes are temp dirs created inside the
46 * test, so the layer is built per run rather than shared.
47 */
48interface ScannerTestInput {
49 readonly claudeHomePath: string;
50 readonly codexHomePath: string;
51 readonly importedWorkspaceRoots?: ReadonlyArray<string>;
52 /** Base dir for the test ServerConfig; worktreesDir derives from it. */
53 readonly configBaseDir?: string;
54 readonly providerInstances?: ContractServerSettings["providerInstances"];
55}
56
57const layerScannerTest = (input: ScannerTestInput) =>
58 AgentSessionScanner.layer.pipe(
59 Layer.provide(
60 Layer.mergeAll(
61 ServerSettings.layerTest({
62 // Default slots point at the fixture homes; explicit test instances
63 // replace a slot with the same id.
64 providerInstances: {
65 [ProviderInstanceId.make("claudeAgent")]: {
66 driver: ProviderDriverKind.make("claudeAgent"),
67 config: { homePath: input.claudeHomePath },
68 },
69 [ProviderInstanceId.make("codex")]: {
70 driver: ProviderDriverKind.make("codex"),
71 config: { homePath: input.codexHomePath },
72 },
73 ...input.providerInstances,
74 },
75 }),
76 ServerConfig.layerTest(
77 input.claudeHomePath,
78 input.configBaseDir ?? { prefix: "t3code-scanner-config-" },
79 ),
80 layerProjectStore(input.importedWorkspaceRoots ?? []),
81 ),
82 ),
83 );
84
85const runScan = (input: ScannerTestInput) =>
86 Effect.gen(function* () {
87 const scanner = yield* AgentSessionScanner.AgentSessionScanner;
88 return yield* scanner.scan;
89 }).pipe(Effect.provide(layerScannerTest(input)));
90
91const runRecentThreadOutcomes = (input: ScannerTestInput & { readonly workspaceRoot: string }) =>
92 Effect.gen(function* () {
93 const scanner = yield* AgentSessionScanner.AgentSessionScanner;
94 return yield* scanner.recentThreads(input.workspaceRoot).pipe(
95 Stream.runCollect,
96 Effect.map((outcomes) => Array.from(outcomes)),
97 );
98 }).pipe(Effect.provide(layerScannerTest(input)));
99
100const runRecentThreads = (input: ScannerTestInput & { readonly workspaceRoot: string }) =>
101 runRecentThreadOutcomes(input).pipe(
102 Effect.map((outcomes) =>
103 outcomes.flatMap((outcome) => (outcome._tag === "Importable" ? [outcome.thread] : [])),
104 ),
105 );
106
107const makeTempDir = Effect.fn("AgentSessionScanner.test.makeTempDir")(function* (prefix: string) {
108 const fileSystem = yield* FileSystem.FileSystem;
109 return yield* fileSystem.makeTempDirectoryScoped({ prefix });
110});
111
112const writeTranscript = Effect.fn("AgentSessionScanner.test.writeTranscript")(function* (input: {
113 readonly filePath: string;
114 readonly contents: string;
115 /** Epoch millis, so ordering assertions never depend on write timing. */
116 readonly mtimeMs: number;
117}) {
118 const fileSystem = yield* FileSystem.FileSystem;
119 const path = yield* Path.Path;
120 yield* fileSystem.makeDirectory(path.dirname(input.filePath), { recursive: true });
121 yield* fileSystem.writeFileString(input.filePath, input.contents);
122 // Numeric utimes arguments are seconds, not milliseconds.
123 const seconds = input.mtimeMs / 1000;
124 yield* fileSystem.utimes(input.filePath, seconds, seconds);
125});
126
127/** Claude session line: the first record carries the real `cwd`. */
128const claudeSessionLine = (cwd: string) =>
129 `${JSON.stringify({ type: "user", cwd, sessionId: "s1" })}\n${JSON.stringify({ type: "assistant" })}\n`;
130
131/** Codex rollout line: session metadata is nested under `payload`. */
132const codexRolloutLine = (cwd: string) =>
133 `${JSON.stringify({ timestamp: "2026-01-01T00:00:00.000Z", type: "session_meta", payload: { id: "r1", cwd } })}\n`;
134
135const encodeTranscriptRecord = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
136
137function makeRecordLimitTranscript(cwd: string, overflow: boolean): string {
138 const records =
139 [
140 encodeTranscriptRecord({
141 type: "session_meta",
142 payload: { id: "record-limit-session", cwd },
143 }),
144 encodeTranscriptRecord({
145 type: "event_msg",
146 payload: { type: "user_message", message: "First prompt" },
147 }),
148 ].join("\n") +
149 "\n" +
150 "{}\n".repeat(99_998);
151 return overflow
152 ? records +
153 "\n" +
154 encodeTranscriptRecord({
155 type: "event_msg",
156 payload: { type: "user_message", message: "Overflow prompt" },
157 }) +
158 "\n"
159 : records;
160}
161
162it.layer(NodeServices.layer)("AgentSessionScanner", (it) => {
163 describe("scan", () => {
164 it.effect("reads Claude project cwds from transcripts, newest first", () =>
165 Effect.gen(function* () {
166 const path = yield* Path.Path;
167 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
168 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
169 const olderWorkspace = yield* makeTempDir("t3code-workspace-older-");
170 const newerWorkspace = yield* makeTempDir("t3code-workspace-newer-");
171
172 // Slugs are intentionally lossy; the scanner must not decode them.
173 yield* writeTranscript({
174 filePath: path.join(claudeHomePath, "projects", "-slug-older", "a.jsonl"),
175 contents: claudeSessionLine(olderWorkspace),
176 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
177 });
178 yield* writeTranscript({
179 filePath: path.join(claudeHomePath, "projects", "-slug-older", "b.jsonl"),
180 contents: claudeSessionLine(olderWorkspace),
181 mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"),
182 });
183 yield* writeTranscript({
184 filePath: path.join(claudeHomePath, "projects", "-slug-newer", "c.jsonl"),
185 contents: claudeSessionLine(newerWorkspace),
186 mtimeMs: Date.parse("2026-03-01T00:00:00.000Z"),
187 });
188
189 const result = yield* runScan({ claudeHomePath, codexHomePath });
190
191 expect(result.candidates).toEqual([
192 {
193 path: newerWorkspace,
194 title: path.basename(newerWorkspace),
195 sources: ["claudeAgent"],
196 threadCount: 1,
197 lastActiveAt: "2026-03-01T00:00:00.000Z",
198 alreadyImported: false,
199 git: null,
200 },
201 {
202 path: olderWorkspace,
203 title: path.basename(olderWorkspace),
204 sources: ["claudeAgent"],
205 threadCount: 2,
206 lastActiveAt: "2026-01-02T00:00:00.000Z",
207 alreadyImported: false,
208 git: null,
209 },
210 ]);
211 }),
212 );
213
214 it.effect("groups Codex rollouts by cwd across date directories", () =>
215 Effect.gen(function* () {
216 const path = yield* Path.Path;
217 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
218 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
219 const workspace = yield* makeTempDir("t3code-workspace-");
220 const otherWorkspace = yield* makeTempDir("t3code-workspace-other-");
221
222 const rollout = (year: string, month: string, day: string, name: string) =>
223 path.join(codexHomePath, "sessions", year, month, day, name);
224
225 yield* writeTranscript({
226 filePath: rollout("2026", "01", "05", "rollout-2026-01-05T10-00-00-aaa.jsonl"),
227 contents: codexRolloutLine(workspace),
228 mtimeMs: Date.parse("2026-01-05T10:00:00.000Z"),
229 });
230 yield* writeTranscript({
231 filePath: rollout("2026", "02", "09", "rollout-2026-02-09T10-00-00-bbb.jsonl"),
232 contents: codexRolloutLine(workspace),
233 mtimeMs: Date.parse("2026-02-09T10:00:00.000Z"),
234 });
235 yield* writeTranscript({
236 filePath: rollout("2026", "02", "09", "rollout-2026-02-09T11-00-00-ccc.jsonl"),
237 contents: codexRolloutLine(otherWorkspace),
238 mtimeMs: Date.parse("2026-02-09T11:00:00.000Z"),
239 });
240
241 const result = yield* runScan({ claudeHomePath, codexHomePath });
242
243 expect(result.candidates).toEqual([
244 {
245 path: otherWorkspace,
246 title: path.basename(otherWorkspace),
247 sources: ["codex"],
248 threadCount: 1,
249 lastActiveAt: "2026-02-09T11:00:00.000Z",
250 alreadyImported: false,
251 git: null,
252 },
253 {
254 path: workspace,
255 title: path.basename(workspace),
256 sources: ["codex"],
257 threadCount: 2,
258 lastActiveAt: "2026-02-09T10:00:00.000Z",
259 alreadyImported: false,
260 git: null,
261 },
262 ]);
263 }),
264 );
265
266 it.effect.each(["claudeAgent", "codex"] as const)(
267 "does not open a non-file %s transcript",
268 (source) =>
269 Effect.gen(function* () {
270 const path = yield* Path.Path;
271 const fileSystem = yield* FileSystem.FileSystem;
272 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
273 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
274 const transcriptPath =
275 source === "claudeAgent"
276 ? path.join(claudeHomePath, "projects", "-slug", "session.jsonl")
277 : path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-session.jsonl");
278 yield* fileSystem.makeDirectory(transcriptPath, { recursive: true });
279
280 let transcriptOpenCount = 0;
281 const simulatedFileSystem = FileSystem.FileSystem.of({
282 ...fileSystem,
283 open: (filePath, options) => {
284 if (filePath === transcriptPath) transcriptOpenCount += 1;
285 return fileSystem.open(filePath, options);
286 },
287 });
288
289 const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe(
290 Effect.provideService(FileSystem.FileSystem, simulatedFileSystem),
291 );
292
293 expect(result.candidates).toEqual([]);
294 expect(transcriptOpenCount).toBe(0);
295 }),
296 );
297
298 it.effect.each(["claudeAgent", "codex"] as const)(
299 "stops %s directory reads at the discovery operation budget",
300 (source) =>
301 Effect.gen(function* () {
302 const path = yield* Path.Path;
303 const fileSystem = yield* FileSystem.FileSystem;
304 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
305 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
306 const discoveryRoot =
307 source === "claudeAgent"
308 ? path.join(claudeHomePath, "projects")
309 : path.join(codexHomePath, "sessions");
310 const emptyDirectories = Array.from(
311 { length: 20_001 },
312 (_, index) => `empty-${index.toString().padStart(5, "0")}`,
313 );
314 let directoryReadCount = 0;
315 const simulatedFileSystem = FileSystem.FileSystem.of({
316 ...fileSystem,
317 readDirectory: (directory, options) => {
318 if (directory === discoveryRoot) {
319 directoryReadCount += 1;
320 return Effect.succeed(emptyDirectories);
321 }
322 if (path.dirname(directory) === discoveryRoot) {
323 directoryReadCount += 1;
324 return Effect.succeed([]);
325 }
326 return fileSystem.readDirectory(directory, options);
327 },
328 });
329
330 const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe(
331 Effect.provideService(FileSystem.FileSystem, simulatedFileSystem),
332 );
333
334 expect(result.candidates).toEqual([]);
335 expect(directoryReadCount).toBe(20_000);
336 }),
337 );
338
339 it.effect("merges the same cwd seen by both agents and flags imported projects", () =>
340 Effect.gen(function* () {
341 const path = yield* Path.Path;
342 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
343 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
344 const workspace = yield* makeTempDir("t3code-workspace-");
345
346 yield* writeTranscript({
347 filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"),
348 contents: claudeSessionLine(workspace),
349 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
350 });
351 yield* writeTranscript({
352 filePath: path.join(
353 codexHomePath,
354 "sessions",
355 "2026",
356 "04",
357 "01",
358 "rollout-2026-04-01T09-00-00-aaa.jsonl",
359 ),
360 contents: codexRolloutLine(workspace),
361 mtimeMs: Date.parse("2026-04-01T09:00:00.000Z"),
362 });
363
364 const result = yield* runScan({
365 claudeHomePath,
366 codexHomePath,
367 importedWorkspaceRoots: [workspace],
368 });
369
370 expect(result.candidates).toEqual([
371 {
372 path: workspace,
373 title: path.basename(workspace),
374 projectId: ProjectId.make("project-1"),
375 sources: ["claudeAgent", "codex"],
376 threadCount: 2,
377 lastActiveAt: "2026-04-01T09:00:00.000Z",
378 alreadyImported: true,
379 git: null,
380 },
381 ]);
382 }),
383 );
384
385 it.effect("returns the imported project ID through a realpath alias", () =>
386 Effect.gen(function* () {
387 const path = yield* Path.Path;
388 const fileSystem = yield* FileSystem.FileSystem;
389 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
390 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
391 const workspace = yield* makeTempDir("t3code-workspace-");
392 const linkParent = yield* makeTempDir("t3code-scanner-links-");
393 const workspaceAlias = path.join(linkParent, "workspace-alias");
394 yield* fileSystem.symlink(workspace, workspaceAlias);
395
396 yield* writeTranscript({
397 filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"),
398 contents: claudeSessionLine(workspaceAlias),
399 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
400 });
401
402 const result = yield* runScan({
403 claudeHomePath,
404 codexHomePath,
405 importedWorkspaceRoots: [workspace],
406 });
407
408 expect(result.candidates[0]).toMatchObject({
409 path: workspace,
410 projectId: ProjectId.make("project-1"),
411 alreadyImported: true,
412 git: null,
413 });
414 }),
415 );
416
417 it.effect("matches a persisted project alias to a transcript realpath", () =>
418 Effect.gen(function* () {
419 const path = yield* Path.Path;
420 const fileSystem = yield* FileSystem.FileSystem;
421 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
422 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
423 const workspace = yield* makeTempDir("t3code-workspace-");
424 const linkParent = yield* makeTempDir("t3code-scanner-links-");
425 const workspaceAlias = path.join(linkParent, "workspace-alias");
426 yield* fileSystem.symlink(workspace, workspaceAlias);
427
428 yield* writeTranscript({
429 filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"),
430 contents: claudeSessionLine(workspace),
431 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
432 });
433
434 const result = yield* runScan({
435 claudeHomePath,
436 codexHomePath,
437 importedWorkspaceRoots: [workspaceAlias],
438 });
439
440 expect(result.candidates[0]).toMatchObject({
441 path: workspaceAlias,
442 projectId: ProjectId.make("project-1"),
443 alreadyImported: true,
444 git: null,
445 });
446 }),
447 );
448
449 it.effect("merges case aliases and preserves the persisted project path", () =>
450 Effect.gen(function* () {
451 const path = yield* Path.Path;
452 const fileSystem = yield* FileSystem.FileSystem;
453 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
454 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
455 const workspace = yield* makeTempDir("t3code-workspace-");
456 const workspaceAlias = path.join(
457 path.dirname(workspace),
458 path.basename(workspace).toUpperCase(),
459 );
460
461 yield* writeTranscript({
462 filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"),
463 contents: claudeSessionLine(workspaceAlias),
464 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
465 });
466 yield* writeTranscript({
467 filePath: path.join(codexHomePath, "sessions", "2026", "01", "02", "rollout-b.jsonl"),
468 contents: codexRolloutLine(workspace),
469 mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"),
470 });
471
472 const simulatedFileSystem = FileSystem.FileSystem.of({
473 ...fileSystem,
474 stat: (filePath) => fileSystem.stat(filePath === workspaceAlias ? workspace : filePath),
475 });
476 const result = yield* runScan({
477 claudeHomePath,
478 codexHomePath,
479 importedWorkspaceRoots: [workspace],
480 }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem));
481
482 expect(result.candidates).toEqual([
483 {
484 path: workspace,
485 title: path.basename(workspace),
486 projectId: ProjectId.make("project-1"),
487 sources: ["claudeAgent", "codex"],
488 threadCount: 2,
489 lastActiveAt: "2026-01-02T00:00:00.000Z",
490 alreadyImported: true,
491 git: null,
492 },
493 ]);
494 }),
495 );
496
497 it.effect("keeps case variants distinct when the filesystem identities differ", () =>
498 Effect.gen(function* () {
499 const path = yield* Path.Path;
500 const fileSystem = yield* FileSystem.FileSystem;
501 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
502 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
503 const backingUpper = yield* makeTempDir("t3code-backing-upper-");
504 const backingLower = yield* makeTempDir("t3code-backing-lower-");
505 const aliasParent = yield* makeTempDir("t3code-case-aliases-");
506 const upperWorkspace = path.join(aliasParent, "Repo");
507 const lowerWorkspace = path.join(aliasParent, "repo");
508
509 yield* writeTranscript({
510 filePath: path.join(claudeHomePath, "projects", "-upper", "a.jsonl"),
511 contents: claudeSessionLine(upperWorkspace),
512 mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"),
513 });
514 yield* writeTranscript({
515 filePath: path.join(claudeHomePath, "projects", "-lower", "b.jsonl"),
516 contents: claudeSessionLine(lowerWorkspace),
517 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
518 });
519
520 const simulatedFileSystem = FileSystem.FileSystem.of({
521 ...fileSystem,
522 stat: (filePath) =>
523 fileSystem.stat(
524 filePath === upperWorkspace
525 ? backingUpper
526 : filePath === lowerWorkspace
527 ? backingLower
528 : filePath,
529 ),
530 });
531 const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe(
532 Effect.provideService(FileSystem.FileSystem, simulatedFileSystem),
533 );
534
535 expect(result.candidates.map((candidate) => candidate.path)).toEqual([
536 upperWorkspace,
537 lowerWorkspace,
538 ]);
539 }),
540 );
541
542 it.effect("uses explicit default-slot homes instead of the fixture homes", () =>
543 Effect.gen(function* () {
544 const path = yield* Path.Path;
545 const claudeHomePath = yield* makeTempDir("t3code-claude-legacy-");
546 const codexHomePath = yield* makeTempDir("t3code-codex-legacy-");
547 const claudeInstanceHome = yield* makeTempDir("t3code-claude-instance-");
548 const codexInstanceHome = yield* makeTempDir("t3code-codex-instance-");
549 const legacyWorkspace = yield* makeTempDir("t3code-workspace-legacy-");
550 const claudeWorkspace = yield* makeTempDir("t3code-workspace-claude-");
551 const codexWorkspace = yield* makeTempDir("t3code-workspace-codex-");
552
553 yield* writeTranscript({
554 filePath: path.join(claudeHomePath, "projects", "-legacy", "session.jsonl"),
555 contents: claudeSessionLine(legacyWorkspace),
556 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
557 });
558 yield* writeTranscript({
559 filePath: path.join(claudeInstanceHome, "projects", "-actual", "session.jsonl"),
560 contents: claudeSessionLine(claudeWorkspace),
561 mtimeMs: Date.parse("2026-02-01T00:00:00.000Z"),
562 });
563 yield* writeTranscript({
564 filePath: path.join(
565 codexInstanceHome,
566 "sessions",
567 "2026",
568 "03",
569 "01",
570 "rollout-instance.jsonl",
571 ),
572 contents: codexRolloutLine(codexWorkspace),
573 mtimeMs: Date.parse("2026-03-01T00:00:00.000Z"),
574 });
575
576 const result = yield* runScan({
577 claudeHomePath,
578 codexHomePath,
579 providerInstances: {
580 [ProviderInstanceId.make("claudeAgent")]: {
581 driver: ProviderDriverKind.make("claudeAgent"),
582 config: { homePath: claudeInstanceHome },
583 },
584 [ProviderInstanceId.make("codex")]: {
585 driver: ProviderDriverKind.make("codex"),
586 config: { homePath: codexInstanceHome },
587 },
588 },
589 });
590
591 expect(result.candidates.map((candidate) => candidate.path)).toEqual([
592 codexWorkspace,
593 claudeWorkspace,
594 ]);
595 }),
596 );
597
598 it.effect("scans each distinct home across multiple instances once", () =>
599 Effect.gen(function* () {
600 const path = yield* Path.Path;
601 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
602 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
603 const otherCodexHome = yield* makeTempDir("t3code-codex-other-");
604 const workspace = yield* makeTempDir("t3code-workspace-");
605 const otherWorkspace = yield* makeTempDir("t3code-workspace-other-");
606
607 for (const [home, cwd] of [
608 [codexHomePath, workspace],
609 [otherCodexHome, otherWorkspace],
610 ] as const) {
611 yield* writeTranscript({
612 filePath: path.join(home, "sessions", "2026", "01", "01", "rollout-session.jsonl"),
613 contents: codexRolloutLine(cwd),
614 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
615 });
616 }
617
618 const result = yield* runScan({
619 claudeHomePath,
620 codexHomePath,
621 providerInstances: {
622 [ProviderInstanceId.make("codex-personal")]: {
623 driver: ProviderDriverKind.make("codex"),
624 config: { homePath: codexHomePath },
625 },
626 [ProviderInstanceId.make("codex-work")]: {
627 driver: ProviderDriverKind.make("codex"),
628 config: { homePath: otherCodexHome },
629 },
630 },
631 });
632
633 expect(result.candidates).toHaveLength(2);
634 expect(result.candidates.map((candidate) => candidate.threadCount)).toEqual([1, 1]);
635 expect(result.candidates.map((candidate) => candidate.path).sort()).toEqual(
636 [workspace, otherWorkspace].sort(),
637 );
638 }),
639 );
640
641 it.effect("honors provider instance home directory environment variables", () =>
642 Effect.gen(function* () {
643 const path = yield* Path.Path;
644 const claudeHomePath = yield* makeTempDir("t3code-claude-legacy-");
645 const codexHomePath = yield* makeTempDir("t3code-codex-legacy-");
646 const claudeEnvironmentHome = yield* makeTempDir("t3code-claude-env-");
647 const codexEnvironmentHome = yield* makeTempDir("t3code-codex-env-");
648 const claudeWorkspace = yield* makeTempDir("t3code-workspace-claude-");
649 const codexWorkspace = yield* makeTempDir("t3code-workspace-codex-");
650
651 yield* writeTranscript({
652 filePath: path.join(claudeEnvironmentHome, "projects", "-actual", "session.jsonl"),
653 contents: claudeSessionLine(claudeWorkspace),
654 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
655 });
656 yield* writeTranscript({
657 filePath: path.join(
658 codexEnvironmentHome,
659 "sessions",
660 "2026",
661 "01",
662 "01",
663 "rollout-session.jsonl",
664 ),
665 contents: codexRolloutLine(codexWorkspace),
666 mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"),
667 });
668
669 const result = yield* runScan({
670 claudeHomePath,
671 codexHomePath,
672 providerInstances: {
673 [ProviderInstanceId.make("claudeAgent")]: {
674 driver: ProviderDriverKind.make("claudeAgent"),
675 environment: [
676 { name: "CLAUDE_CONFIG_DIR", value: claudeEnvironmentHome, sensitive: false },
677 ],
678 config: {},
679 },
680 [ProviderInstanceId.make("codex")]: {
681 driver: ProviderDriverKind.make("codex"),
682 environment: [{ name: "CODEX_HOME", value: codexEnvironmentHome, sensitive: false }],
683 config: {},
684 },
685 },
686 });
687
688 expect(result.candidates.map((candidate) => candidate.path)).toEqual([
689 codexWorkspace,
690 claudeWorkspace,
691 ]);
692 }),
693 );
694
695 it.effect("ignores invalid provider instances while scanning the remaining providers", () =>
696 Effect.gen(function* () {
697 const path = yield* Path.Path;
698 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
699 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
700 const workspace = yield* makeTempDir("t3code-workspace-");
701
702 yield* writeTranscript({
703 filePath: path.join(claudeHomePath, "projects", "-actual", "session.jsonl"),
704 contents: claudeSessionLine(workspace),
705 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
706 });
707
708 const result = yield* runScan({
709 claudeHomePath,
710 codexHomePath,
711 providerInstances: {
712 [ProviderInstanceId.make("codex")]: {
713 driver: ProviderDriverKind.make("codex"),
714 config: { homePath: 123 },
715 },
716 },
717 });
718
719 expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]);
720 }),
721 );
722
723 it.effect("does not scan provider instances disabled by the envelope or config", () =>
724 Effect.gen(function* () {
725 const path = yield* Path.Path;
726 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
727 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
728 const envelopeDisabledHome = yield* makeTempDir("t3code-codex-disabled-envelope-");
729 const configDisabledHome = yield* makeTempDir("t3code-codex-disabled-config-");
730 const envelopeWorkspace = yield* makeTempDir("t3code-workspace-disabled-envelope-");
731 const configWorkspace = yield* makeTempDir("t3code-workspace-disabled-config-");
732
733 for (const [home, workspace, session] of [
734 [envelopeDisabledHome, envelopeWorkspace, "envelope-disabled"],
735 [configDisabledHome, configWorkspace, "config-disabled"],
736 ] as const) {
737 yield* writeTranscript({
738 filePath: path.join(home, "sessions", "2026", "08", "24", `rollout-${session}.jsonl`),
739 contents: codexRolloutLine(workspace),
740 mtimeMs: Date.parse("2026-08-24T12:00:00.000Z"),
741 });
742 }
743
744 const result = yield* runScan({
745 claudeHomePath,
746 codexHomePath,
747 providerInstances: {
748 [ProviderInstanceId.make("codex-envelope-disabled")]: {
749 driver: ProviderDriverKind.make("codex"),
750 enabled: false,
751 config: { homePath: envelopeDisabledHome },
752 },
753 [ProviderInstanceId.make("codex-config-disabled")]: {
754 driver: ProviderDriverKind.make("codex"),
755 config: { enabled: false, homePath: configDisabledHome },
756 },
757 },
758 });
759
760 expect(result.candidates).toEqual([]);
761 }),
762 );
763
764 it.effect("ignores relative working directories from malformed transcripts", () =>
765 Effect.gen(function* () {
766 const path = yield* Path.Path;
767 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
768 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
769 const workspace = yield* makeTempDir("t3code-workspace-");
770
771 yield* writeTranscript({
772 filePath: path.join(claudeHomePath, "projects", "-relative", "session.jsonl"),
773 contents: claudeSessionLine(path.relative(path.resolve(), workspace)),
774 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
775 });
776
777 const result = yield* runScan({ claudeHomePath, codexHomePath });
778
779 expect(result.candidates).toEqual([]);
780 }),
781 );
782
783 it.effect("drops candidates whose directory no longer exists", () =>
784 Effect.gen(function* () {
785 const path = yield* Path.Path;
786 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
787 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
788
789 yield* writeTranscript({
790 filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"),
791 contents: claudeSessionLine(path.join(claudeHomePath, "does-not-exist")),
792 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
793 });
794
795 const result = yield* runScan({ claudeHomePath, codexHomePath });
796
797 expect(result.candidates).toEqual([]);
798 }),
799 );
800
801 it.effect("excludes the home directory, temporary root, and T3 data directory", () =>
802 Effect.gen(function* () {
803 const path = yield* Path.Path;
804 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
805 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
806 const configBaseDir = yield* makeTempDir("t3code-scanner-base-");
807 const workspace = yield* makeTempDir("t3code-workspace-");
808
809 for (const [index, cwd] of [
810 NodeOS.homedir(),
811 NodeOS.tmpdir(),
812 configBaseDir,
813 workspace,
814 ].entries()) {
815 yield* writeTranscript({
816 filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, "session.jsonl"),
817 contents: claudeSessionLine(cwd),
818 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z") + index,
819 });
820 }
821
822 const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir });
823
824 expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]);
825 }),
826 );
827
828 it.effect("excludes T3-managed worktree sandboxes", () =>
829 Effect.gen(function* () {
830 const path = yield* Path.Path;
831 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
832 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
833 const fileSystem = yield* FileSystem.FileSystem;
834
835 const worktreeCwd = path.join(claudeHomePath, ".t3", "worktrees", "t3code", "wt-1");
836 yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true });
837 yield* writeTranscript({
838 filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"),
839 contents: claudeSessionLine(worktreeCwd),
840 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
841 });
842
843 const result = yield* runScan({ claudeHomePath, codexHomePath });
844
845 expect(result.candidates).toEqual([]);
846 }),
847 );
848
849 it.effect("excludes Codex scratch directories and Downloads", () =>
850 Effect.gen(function* () {
851 const path = yield* Path.Path;
852 const fileSystem = yield* FileSystem.FileSystem;
853 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
854 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
855 // The exclusions key off the real home directory, so these fixtures
856 // must live there. Each run owns a uniquely named subtree and removes
857 // only that subtree, never the shared Codex or Downloads parents.
858 const home = NodeOS.homedir();
859 // Borrow a unique suffix from a scoped temp dir instead of reaching for
860 // Date.now or Math.random, which the Effect lint rejects.
861 const runId = path.basename(yield* makeTempDir("t3code-scanner-test-"));
862 const scratchRoot = path.join(home, "Documents", "Codex", runId);
863 const scratch = path.join(scratchRoot, "2026-09-01", "some-conversation");
864 const downloads = path.join(home, "Downloads", runId);
865 const keep = yield* makeTempDir("t3code-workspace-keep-");
866 yield* fileSystem.makeDirectory(scratch, { recursive: true });
867 yield* fileSystem.makeDirectory(downloads, { recursive: true });
868 yield* Effect.addFinalizer(() =>
869 Effect.all([
870 fileSystem.remove(scratchRoot, { recursive: true }).pipe(Effect.ignore),
871 fileSystem.remove(downloads, { recursive: true }).pipe(Effect.ignore),
872 ]),
873 );
874
875 for (const [index, cwd] of [scratch, downloads, keep].entries()) {
876 yield* writeTranscript({
877 filePath: path.join(
878 codexHomePath,
879 "sessions",
880 "2026",
881 "09",
882 "01",
883 `rollout-${index}.jsonl`,
884 ),
885 contents: codexRolloutLine(cwd),
886 mtimeMs: Date.parse("2026-09-01T00:00:00.000Z"),
887 });
888 }
889
890 const result = yield* runScan({ claudeHomePath, codexHomePath });
891
892 expect(result.candidates.map((candidate) => candidate.path)).toEqual([keep]);
893 }),
894 );
895
896 it.effect("skips linked git worktrees and reports the origin of real checkouts", () =>
897 Effect.gen(function* () {
898 const path = yield* Path.Path;
899 const fileSystem = yield* FileSystem.FileSystem;
900 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
901 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
902 const repo = yield* makeTempDir("t3code-workspace-repo-");
903 const worktree = yield* makeTempDir("t3code-workspace-worktree-");
904 const plain = yield* makeTempDir("t3code-workspace-plain-");
905 const noRemote = yield* makeTempDir("t3code-workspace-noremote-");
906 const submodule = yield* makeTempDir("t3code-workspace-submodule-");
907
908 yield* fileSystem.makeDirectory(path.join(repo, ".git"));
909 yield* fileSystem.writeFileString(
910 path.join(repo, ".git", "config"),
911 '[core]\n\tbare = false\n[remote "origin"]\n\turl = git@github.com:pingdotgg/t3code.git\n\tfetch = +refs/heads/*:refs/remotes/origin/*\n',
912 );
913 yield* fileSystem.writeFileString(
914 path.join(worktree, ".git"),
915 `gitdir: ${path.join(repo, ".git", "worktrees", "wt")}\n`,
916 );
917 yield* fileSystem.makeDirectory(path.join(noRemote, ".git"));
918 yield* fileSystem.writeFileString(path.join(noRemote, ".git", "config"), "[core]\n");
919 // Submodules also use a gitdir pointer, but into `modules/`, not `worktrees/`.
920 const submoduleGitDir = path.join(repo, ".git", "modules", "vendor");
921 yield* fileSystem.makeDirectory(submoduleGitDir, { recursive: true });
922 yield* fileSystem.writeFileString(
923 path.join(submoduleGitDir, "config"),
924 '[remote "origin"]\n\turl = ssh://github.com/pingdotgg/vendor.git\n',
925 );
926 yield* fileSystem.writeFileString(
927 path.join(submodule, ".git"),
928 `gitdir: ${submoduleGitDir}\n`,
929 );
930
931 for (const [index, cwd] of [repo, worktree, plain, noRemote, submodule].entries()) {
932 yield* writeTranscript({
933 filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, "a.jsonl"),
934 contents: claudeSessionLine(cwd),
935 mtimeMs: Date.parse(`2026-01-0${index + 1}T00:00:00.000Z`),
936 });
937 }
938
939 const result = yield* runScan({ claudeHomePath, codexHomePath });
940
941 expect(
942 result.candidates.map((candidate) => ({ path: candidate.path, git: candidate.git })),
943 ).toEqual([
944 {
945 path: submodule,
946 git: { remoteKey: "github.com/pingdotgg/vendor", repository: "pingdotgg/vendor" },
947 },
948 { path: noRemote, git: { remoteKey: null, repository: null } },
949 { path: plain, git: null },
950 {
951 path: repo,
952 git: { remoteKey: "github.com/pingdotgg/t3code", repository: "pingdotgg/t3code" },
953 },
954 ]);
955 }),
956 );
957
958 it.effect("excludes sandboxes under the configured worktrees dir without .t3 in the path", () =>
959 Effect.gen(function* () {
960 const path = yield* Path.Path;
961 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
962 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
963 const configBaseDir = yield* makeTempDir("t3code-scanner-base-");
964 const fileSystem = yield* FileSystem.FileSystem;
965
966 // worktreesDir derives as `<baseDir>/worktrees`, and the temp base
967 // dir contains no `.t3` segment — only the config-based prefix match
968 // can exclude this one.
969 const worktreeCwd = path.join(configBaseDir, "worktrees", "t3code", "wt-2");
970 yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true });
971 yield* writeTranscript({
972 filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"),
973 contents: claudeSessionLine(worktreeCwd),
974 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
975 });
976
977 const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir });
978
979 expect(result.candidates).toEqual([]);
980 }),
981 );
982
983 it.effect("excludes sandboxes reached through a symlink into the worktrees dir", () =>
984 Effect.gen(function* () {
985 const path = yield* Path.Path;
986 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
987 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
988 const configBaseDir = yield* makeTempDir("t3code-scanner-base-");
989 const linkParent = yield* makeTempDir("t3code-scanner-links-");
990 const fileSystem = yield* FileSystem.FileSystem;
991
992 // The recorded cwd is a symlink whose own spelling looks harmless;
993 // only its realpath reveals the managed sandbox.
994 const worktreeCwd = path.join(configBaseDir, "worktrees", "t3code", "wt-3");
995 yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true });
996 const symlinkCwd = path.join(linkParent, "innocent-project");
997 yield* fileSystem.symlink(worktreeCwd, symlinkCwd);
998 yield* writeTranscript({
999 filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"),
1000 contents: claudeSessionLine(symlinkCwd),
1001 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
1002 });
1003
1004 const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir });
1005
1006 expect(result.candidates).toEqual([]);
1007 }),
1008 );
1009
1010 it.effect("finds the cwd on a later line when the first records carry none", () =>
1011 Effect.gen(function* () {
1012 const path = yield* Path.Path;
1013 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
1014 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
1015 const workspace = yield* makeTempDir("t3code-workspace-");
1016
1017 // Claude transcripts often open with records that have no cwd.
1018 const contents = `{"type":"file-history-snapshot","messageId":"m1"}\n{"type":"queue-operation","operation":"enqueue"}\n${claudeSessionLine(workspace)}`;
1019 yield* writeTranscript({
1020 filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"),
1021 contents,
1022 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
1023 });
1024
1025 const result = yield* runScan({ claudeHomePath, codexHomePath });
1026
1027 expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]);
1028 }),
1029 );
1030
1031 it.effect("reads a complete transcript record at the exact chunk boundary", () =>
1032 Effect.gen(function* () {
1033 const path = yield* Path.Path;
1034 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
1035 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
1036 const workspace = yield* makeTempDir("t3code-workspace-");
1037 const record = claudeSessionLine(workspace).split("\n")[0]!;
1038 const prefix = '{"padding":"';
1039 const suffix = `",${record.slice(1)}`;
1040 const contents = `${prefix}${"x".repeat(32 * 1024 - prefix.length - suffix.length)}${suffix}`;
1041
1042 yield* writeTranscript({
1043 filePath: path.join(claudeHomePath, "projects", "-exact", "session.jsonl"),
1044 contents,
1045 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
1046 });
1047
1048 const result = yield* runScan({ claudeHomePath, codexHomePath });
1049
1050 expect(contents).toHaveLength(32 * 1024);
1051 expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]);
1052 }),
1053 );
1054
1055 it.effect("finds session metadata after a first record larger than one chunk", () =>
1056 Effect.gen(function* () {
1057 const path = yield* Path.Path;
1058 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
1059 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
1060 const workspace = yield* makeTempDir("t3code-workspace-");
1061 const history = `{"type":"file-history-snapshot","data":"${"x".repeat(32 * 1024)}"}\n`;
1062
1063 yield* writeTranscript({
1064 filePath: path.join(claudeHomePath, "projects", "-large", "session.jsonl"),
1065 contents: `${history}${claudeSessionLine(workspace)}`,
1066 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
1067 });
1068
1069 const result = yield* runScan({ claudeHomePath, codexHomePath });
1070
1071 expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]);
1072 }),
1073 );
1074
1075 it.effect.each([64, 65])("shares metadata bytes across homes for %s one-MiB files", (count) =>
1076 Effect.gen(function* () {
1077 const path = yield* Path.Path;
1078 const fileSystem = yield* FileSystem.FileSystem;
1079 const claudeHomePath = yield* makeTempDir("t3code-metadata-home-");
1080 const secondHome = yield* makeTempDir("t3code-metadata-second-");
1081 const codexHomePath = yield* makeTempDir("t3code-metadata-codex-");
1082 const firstWorkspace = yield* makeTempDir("t3code-metadata-first-project-");
1083 const secondWorkspace = yield* makeTempDir("t3code-metadata-second-project-");
1084 const directories = [
1085 path.join(claudeHomePath, "projects", "p"),
1086 path.join(secondHome, "projects", "p"),
1087 ];
1088 const templates = directories.map((directory) => path.join(directory, "template.jsonl"));
1089 for (const [index, workspace] of [firstWorkspace, secondWorkspace].entries()) {
1090 const record = encodeTranscriptRecord({ cwd: workspace });
1091 yield* writeTranscript({
1092 filePath: templates[index]!,
1093 contents:
1094 " ".repeat(1024 * 1024 - new TextEncoder().encode(record).byteLength) + record,
1095 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z") - index * 1_000,
1096 });
1097 }
1098 const resolveFile = (filePath: string) => {
1099 const index = directories.indexOf(path.dirname(filePath));
1100 return index === -1 ? filePath : templates[index]!;
1101 };
1102 let reservedBytes = 0;
1103 let opens = 0;
1104 const requests: number[] = [];
1105 const observedFileSystem = FileSystem.FileSystem.of({
1106 ...fileSystem,
1107 readDirectory: (directory, options) => {
1108 const index = directories.indexOf(directory);
1109 return index === -1
1110 ? fileSystem.readDirectory(directory, options)
1111 : Effect.succeed(
1112 Array.from(
1113 { length: index === 0 ? 32 : count - 32 },
1114 (_, item) => `session-${item}.jsonl`,
1115 ),
1116 );
1117 },
1118 stat: (filePath) => fileSystem.stat(resolveFile(filePath)),
1119 open: (filePath, options) => {
1120 if (!directories.includes(path.dirname(filePath)))
1121 return fileSystem.open(filePath, options);
1122 opens += 1;
1123 return fileSystem.open(resolveFile(filePath), options).pipe(
1124 Effect.map((file) => ({
1125 ...file,
1126 stat: file.stat,
1127 readAlloc: (size: number) => {
1128 reservedBytes += Number(size);
1129 requests.push(Number(size));
1130 return file.readAlloc(size);
1131 },
1132 })),
1133 );
1134 },
1135 });
1136 const result = yield* runScan({
1137 claudeHomePath,
1138 codexHomePath,
1139 providerInstances: {
1140 [ProviderInstanceId.make("claude-work")]: {
1141 driver: ProviderDriverKind.make("claudeAgent"),
1142 config: { homePath: secondHome },
1143 },
1144 },
1145 }).pipe(Effect.provideService(FileSystem.FileSystem, observedFileSystem));
1146 expect(result.candidates.map((candidate) => candidate.path)).toEqual([
1147 firstWorkspace,
1148 secondWorkspace,
1149 ]);
1150 expect(result.candidates.map((candidate) => candidate.threadCount)).toEqual([32, 32]);
1151 expect(result.truncated).toBe(count === 65 ? true : undefined);
1152 expect(opens).toBe(64);
1153 expect(reservedBytes).toBe(64 * 1024 * 1024);
1154 expect(requests[0]).toBe(8 * 1024);
1155 expect(Math.max(...requests)).toBe(8 * 1024);
1156 }),
1157 );
1158
1159 it.effect.each([50, 51])("bounds metadata open/read calls for %s short-read files", (count) =>
1160 Effect.gen(function* () {
1161 const path = yield* Path.Path;
1162 const fileSystem = yield* FileSystem.FileSystem;
1163 const claudeHomePath = yield* makeTempDir("t3code-short-metadata-home-");
1164 const codexHomePath = yield* makeTempDir("t3code-short-metadata-codex-");
1165 const workspace = yield* makeTempDir("t3code-short-metadata-project-");
1166 const directory = path.join(claudeHomePath, "projects", "p");
1167 const template = path.join(directory, "template.jsonl");
1168 const record = encodeTranscriptRecord({ cwd: workspace });
1169 const contents = " ".repeat(399 - new TextEncoder().encode(record).byteLength) + record;
1170 const bytes = new TextEncoder().encode(contents);
1171 yield* writeTranscript({
1172 filePath: template,
1173 contents,
1174 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
1175 });
1176 let operations = 0;
1177 const observedFileSystem = FileSystem.FileSystem.of({
1178 ...fileSystem,
1179 readDirectory: (target, options) =>
1180 target === directory
1181 ? Effect.succeed(
1182 Array.from({ length: count }, (_, index) => `session-${index}.jsonl`),
1183 )
1184 : fileSystem.readDirectory(target, options),
1185 stat: (filePath) =>
1186 fileSystem.stat(path.dirname(filePath) === directory ? template : filePath),
1187 open: (filePath, options) => {
1188 if (path.dirname(filePath) !== directory) return fileSystem.open(filePath, options);
1189 operations += 1;
1190 let offset = 0;
1191 return fileSystem.open(template, options).pipe(
1192 Effect.map((file) => ({
1193 ...file,
1194 stat: file.stat,
1195 readAlloc: () =>
1196 Effect.sync(() => {
1197 operations += 1;
1198 if (offset === bytes.length) return Option.none<Uint8Array>();
1199 return Option.some(bytes.subarray(offset, ++offset));
1200 }),
1201 })),
1202 );
1203 },
1204 });
1205 const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe(
1206 Effect.provideService(FileSystem.FileSystem, observedFileSystem),
1207 );
1208 expect(operations).toBe(20_000);
1209 expect(result.candidates[0]?.threadCount).toBe(50);
1210 expect(result.truncated).toBe(count === 51 ? true : undefined);
1211 }),
1212 );
1213
1214 it.effect("bounds malformed metadata records without excluding another account", () =>
1215 Effect.gen(function* () {
1216 const path = yield* Path.Path;
1217 const fileSystem = yield* FileSystem.FileSystem;
1218 const claudeHomePath = yield* makeTempDir("t3code-record-metadata-home-");
1219 const secondHome = yield* makeTempDir("t3code-record-metadata-second-");
1220 const codexHomePath = yield* makeTempDir("t3code-record-metadata-codex-");
1221 const workspace = yield* makeTempDir("t3code-record-metadata-project-");
1222 const directory = path.join(claudeHomePath, "projects", "p");
1223 const template = path.join(directory, "template.jsonl");
1224 yield* writeTranscript({
1225 filePath: template,
1226 contents: "x\n".repeat(1_001),
1227 mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"),
1228 });
1229 yield* writeTranscript({
1230 filePath: path.join(secondHome, "projects", "p", "session.jsonl"),
1231 contents: encodeTranscriptRecord({ cwd: workspace }),
1232 mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"),
1233 });
1234 let malformedOpens = 0;
1235 const observedFileSystem = FileSystem.FileSystem.of({
1236 ...fileSystem,
1237 readDirectory: (target, options) =>
1238 target === directory
1239 ? Effect.succeed(Array.from({ length: 102 }, (_, index) => `session-${index}.jsonl`))
1240 : fileSystem.readDirectory(target, options),
1241 stat: (filePath) =>
1242 fileSystem.stat(path.dirname(filePath) === directory ? template : filePath),
1243 open: (filePath, options) => {
1244 if (path.dirname(filePath) !== directory) return fileSystem.open(filePath, options);
1245 malformedOpens += 1;
1246 return fileSystem.open(template, options);
1247 },
1248 });
1249 const result = yield* runScan({
1250 claudeHomePath,
1251 codexHomePath,
1252 providerInstances: {
1253 [ProviderInstanceId.make("claude-work")]: {
1254 driver: ProviderDriverKind.make("claudeAgent"),
1255 config: { homePath: secondHome },
1256 },
1257 },
1258 }).pipe(Effect.provideService(FileSystem.FileSystem, observedFileSystem));
1259 expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]);
1260 expect(malformedOpens).toBe(100);
1261 expect(result.truncated).toBe(true);
1262 }),
1263 );
1264
1265 it.effect.each([19_999, 20_000])(
1266 "reports unfinished directory work for %s project directories",
1267 (count) =>
1268 Effect.gen(function* () {
1269 const path = yield* Path.Path;
1270 const fileSystem = yield* FileSystem.FileSystem;
1271 const claudeHomePath = yield* makeTempDir("t3code-directory-budget-home-");
1272 const codexHomePath = yield* makeTempDir("t3code-directory-budget-codex-");
1273 const projectsDir = path.join(claudeHomePath, "projects");
1274 let reads = 0;
1275 const observedFileSystem = FileSystem.FileSystem.of({
1276 ...fileSystem,
1277 readDirectory: (directory, options) => {
1278 if (directory === projectsDir) {
1279 reads += 1;
1280 return Effect.succeed(
1281 Array.from({ length: count }, (_, index) => `project-${index}`),
1282 );
1283 }
1284 if (path.dirname(directory) === projectsDir) {
1285 reads += 1;
1286 return Effect.succeed([]);
1287 }
1288 return fileSystem.readDirectory(directory, options);
1289 },
1290 });
1291 const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe(
1292 Effect.provideService(FileSystem.FileSystem, observedFileSystem),
1293 );
1294 expect(reads).toBe(20_000);
1295 expect(result.candidates).toEqual([]);
1296 expect(result.truncated).toBe(count === 20_000 ? true : undefined);
1297 }),
1298 );
1299
1300 it.effect("skips malformed transcripts without failing the scan", () =>
1301 Effect.gen(function* () {
1302 const path = yield* Path.Path;
1303 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
1304 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
1305 const workspace = yield* makeTempDir("t3code-workspace-");
1306
1307 yield* writeTranscript({
1308 filePath: path.join(claudeHomePath, "projects", "-broken", "a.jsonl"),
1309 contents: "not json at all\n",
1310 mtimeMs: Date.parse("2026-05-01T00:00:00.000Z"),
1311 });
1312 // Valid JSON, but no cwd anywhere in the record.
1313 yield* writeTranscript({
1314 filePath: path.join(claudeHomePath, "projects", "-no-cwd", "a.jsonl"),
1315 contents: `{"type":"summary"}\n`,
1316 mtimeMs: Date.parse("2026-05-02T00:00:00.000Z"),
1317 });
1318 yield* writeTranscript({
1319 filePath: path.join(claudeHomePath, "projects", "-good", "a.jsonl"),
1320 contents: claudeSessionLine(workspace),
1321 mtimeMs: Date.parse("2026-05-03T00:00:00.000Z"),
1322 });
1323
1324 const result = yield* runScan({ claudeHomePath, codexHomePath });
1325
1326 expect(result.candidates).toEqual([
1327 {
1328 path: workspace,
1329 title: path.basename(workspace),
1330 sources: ["claudeAgent"],
1331 threadCount: 1,
1332 lastActiveAt: "2026-05-03T00:00:00.000Z",
1333 alreadyImported: false,
1334 git: null,
1335 },
1336 ]);
1337 }),
1338 );
1339
1340 it.effect("returns an empty result when neither home directory exists", () =>
1341 Effect.gen(function* () {
1342 const path = yield* Path.Path;
1343 const root = yield* makeTempDir("t3code-missing-homes-");
1344
1345 const result = yield* runScan({
1346 claudeHomePath: path.join(root, "no-claude"),
1347 codexHomePath: path.join(root, "no-codex"),
1348 });
1349
1350 expect(result.candidates).toEqual([]);
1351 expect(result.scannedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
1352 }),
1353 );
1354 });
1355
1356 describe("recentThreads", () => {
1357 it.effect.each([false, true])(
1358 "counts terminal newlines correctly with record overflow=%s",
1359 (overflow) =>
1360 Effect.gen(function* () {
1361 const path = yield* Path.Path;
1362 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
1363 yield* TestClock.setTime(nowMs);
1364 const claudeHomePath = yield* makeTempDir("t3code-record-limit-claude-");
1365 const codexHomePath = yield* makeTempDir("t3code-record-limit-codex-");
1366 const workspace = yield* makeTempDir("t3code-record-limit-project-");
1367 const directory = path.join(codexHomePath, "sessions", "2026", "08", "24");
1368 yield* writeTranscript({
1369 filePath: path.join(directory, "rollout-records.jsonl"),
1370 contents: makeRecordLimitTranscript(workspace, overflow),
1371 mtimeMs: nowMs,
1372 });
1373 yield* writeTranscript({
1374 filePath: path.join(directory, "rollout-older.jsonl"),
1375 contents: [
1376 encodeTranscriptRecord({
1377 type: "session_meta",
1378 payload: { id: "older-session", cwd: workspace },
1379 }),
1380 encodeTranscriptRecord({
1381 type: "event_msg",
1382 payload: { type: "user_message", message: "Older prompt" },
1383 }),
1384 ].join("\n"),
1385 mtimeMs: nowMs - 1_000,
1386 });
1387 const outcomes = yield* runRecentThreadOutcomes({
1388 claudeHomePath,
1389 codexHomePath,
1390 workspaceRoot: workspace,
1391 });
1392 expect(outcomes.map((outcome) => outcome._tag)).toEqual(
1393 overflow ? ["Skipped", "Importable"] : ["Importable", "Skipped"],
1394 );
1395 expect(
1396 outcomes.flatMap((outcome) =>
1397 outcome._tag === "Importable"
1398 ? outcome.thread.messages.map((message) => message.text)
1399 : [],
1400 ),
1401 ).toEqual([overflow ? "Older prompt" : "First prompt"]);
1402 }),
1403 );
1404
1405 it.effect("imports recent Claude and Codex sessions for the selected project only", () =>
1406 Effect.gen(function* () {
1407 const path = yield* Path.Path;
1408 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
1409 yield* TestClock.setTime(nowMs);
1410 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
1411 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
1412 const workspace = yield* makeTempDir("t3code-workspace-");
1413 const otherWorkspace = yield* makeTempDir("t3code-workspace-other-");
1414
1415 const claudeTranscript = (cwd: string, sessionId: string) =>
1416 `${JSON.stringify({
1417 type: "user",
1418 cwd,
1419 sessionId,
1420 timestamp: "2026-08-23T12:00:00.000Z",
1421 message: { role: "user", content: "Fix the project" },
1422 })}\n${JSON.stringify({
1423 type: "assistant",
1424 sessionId,
1425 timestamp: "2026-08-23T12:01:00.000Z",
1426 message: { role: "assistant", content: [{ type: "text", text: "Done" }] },
1427 })}\n`;
1428
1429 yield* writeTranscript({
1430 filePath: path.join(claudeHomePath, "projects", "-selected", "claude-recent.jsonl"),
1431 contents: claudeTranscript(workspace, "claude-recent"),
1432 mtimeMs: nowMs - 24 * 60 * 60 * 1000,
1433 });
1434 yield* writeTranscript({
1435 filePath: path.join(claudeHomePath, "projects", "-selected", "claude-old.jsonl"),
1436 contents: claudeTranscript(workspace, "claude-old"),
1437 mtimeMs: nowMs - 31 * 24 * 60 * 60 * 1000,
1438 });
1439 yield* writeTranscript({
1440 filePath: path.join(claudeHomePath, "projects", "-other", "claude-other.jsonl"),
1441 contents: claudeTranscript(otherWorkspace, "claude-other"),
1442 mtimeMs: nowMs - 24 * 60 * 60 * 1000,
1443 });
1444 yield* writeTranscript({
1445 filePath: path.join(
1446 codexHomePath,
1447 "sessions",
1448 "2026",
1449 "08",
1450 "24",
1451 "rollout-codex-recent.jsonl",
1452 ),
1453 contents: [
1454 encodeTranscriptRecord({
1455 type: "session_meta",
1456 payload: { id: "codex-recent", cwd: workspace },
1457 }),
1458 encodeTranscriptRecord({
1459 type: "event_msg",
1460 timestamp: "2026-08-24T10:00:00.000Z",
1461 payload: { type: "user_message", message: "Review this code" },
1462 }),
1463 encodeTranscriptRecord({
1464 type: "response_item",
1465 timestamp: "2026-08-24T10:01:00.000Z",
1466 payload: {
1467 type: "message",
1468 role: "assistant",
1469 content: [{ type: "output_text", text: "Looks good" }],
1470 },
1471 }),
1472 ].join("\n"),
1473 mtimeMs: nowMs - 60 * 60 * 1000,
1474 });
1475
1476 const threads = yield* runRecentThreads({
1477 claudeHomePath,
1478 codexHomePath,
1479 workspaceRoot: workspace,
1480 });
1481
1482 expect(threads.map((thread) => thread.providerSessionId)).toEqual([
1483 "codex-recent",
1484 "claude-recent",
1485 ]);
1486 expect(threads.map((thread) => thread.messages.map((message) => message.text))).toEqual([
1487 ["Review this code", "Looks good"],
1488 ["Fix the project", "Done"],
1489 ]);
1490 }),
1491 );
1492
1493 it.effect("imports history recorded with a case alias", () =>
1494 Effect.gen(function* () {
1495 const path = yield* Path.Path;
1496 const fileSystem = yield* FileSystem.FileSystem;
1497 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
1498 yield* TestClock.setTime(nowMs);
1499 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
1500 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
1501 const workspace = yield* makeTempDir("t3code-workspace-");
1502 const workspaceAlias = path.join(
1503 path.dirname(workspace),
1504 path.basename(workspace).toUpperCase(),
1505 );
1506
1507 yield* writeTranscript({
1508 filePath: path.join(claudeHomePath, "projects", "-alias", "case-session.jsonl"),
1509 contents: [
1510 encodeTranscriptRecord({
1511 type: "user",
1512 cwd: workspaceAlias,
1513 sessionId: "case-session",
1514 timestamp: "2026-08-24T10:00:00.000Z",
1515 message: { role: "user", content: "Import case alias history" },
1516 }),
1517 encodeTranscriptRecord({
1518 type: "assistant",
1519 sessionId: "case-session",
1520 timestamp: "2026-08-24T10:01:00.000Z",
1521 message: { role: "assistant", content: "Imported" },
1522 }),
1523 ].join("\n"),
1524 mtimeMs: nowMs,
1525 });
1526
1527 const simulatedFileSystem = FileSystem.FileSystem.of({
1528 ...fileSystem,
1529 stat: (filePath) => fileSystem.stat(filePath === workspaceAlias ? workspace : filePath),
1530 });
1531 const threads = yield* runRecentThreads({
1532 claudeHomePath,
1533 codexHomePath,
1534 workspaceRoot: workspace,
1535 }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem));
1536
1537 expect(threads.map((thread) => thread.providerSessionId)).toEqual(["case-session"]);
1538 }),
1539 );
1540
1541 it.effect("keeps the provider instance that owns a custom session home", () =>
1542 Effect.gen(function* () {
1543 const path = yield* Path.Path;
1544 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
1545 yield* TestClock.setTime(nowMs);
1546 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
1547 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
1548 const customHome = yield* makeTempDir("t3code-codex-custom-");
1549 const workspace = yield* makeTempDir("t3code-workspace-");
1550
1551 yield* writeTranscript({
1552 filePath: path.join(customHome, "sessions", "2026", "08", "24", "rollout-custom.jsonl"),
1553 contents: [
1554 encodeTranscriptRecord({
1555 type: "session_meta",
1556 payload: { id: "custom-session", cwd: workspace },
1557 }),
1558 encodeTranscriptRecord({
1559 type: "event_msg",
1560 payload: { type: "user_message", message: "Use my work account" },
1561 }),
1562 ].join("\n"),
1563 mtimeMs: nowMs,
1564 });
1565
1566 const threads = yield* runRecentThreads({
1567 claudeHomePath,
1568 codexHomePath,
1569 workspaceRoot: workspace,
1570 providerInstances: {
1571 [ProviderInstanceId.make("codex-work")]: {
1572 driver: ProviderDriverKind.make("codex"),
1573 config: { homePath: customHome },
1574 },
1575 },
1576 });
1577
1578 expect(threads[0]?.providerInstanceId).toBe("codex-work");
1579 }),
1580 );
1581
1582 it.effect("suppresses duplicate session copies without reporting a skipped import", () =>
1583 Effect.gen(function* () {
1584 const path = yield* Path.Path;
1585 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
1586 yield* TestClock.setTime(nowMs);
1587 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
1588 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
1589 const workspace = yield* makeTempDir("t3code-workspace-");
1590 const contents = [
1591 encodeTranscriptRecord({
1592 type: "session_meta",
1593 payload: { id: "copied-session", cwd: workspace },
1594 }),
1595 encodeTranscriptRecord({
1596 type: "event_msg",
1597 payload: { type: "user_message", message: "Import this session once" },
1598 }),
1599 ].join("\n");
1600
1601 for (const [name, mtimeMs] of [
1602 ["rollout-copy-a.jsonl", nowMs],
1603 ["rollout-copy-b.jsonl", nowMs - 1],
1604 ] as const) {
1605 yield* writeTranscript({
1606 filePath: path.join(codexHomePath, "sessions", "2026", "08", "24", name),
1607 contents,
1608 mtimeMs,
1609 });
1610 }
1611
1612 const outcomes = yield* runRecentThreadOutcomes({
1613 claudeHomePath,
1614 codexHomePath,
1615 workspaceRoot: workspace,
1616 });
1617
1618 expect(outcomes.map((outcome) => outcome._tag)).toEqual(["Importable", "Duplicate"]);
1619 expect(outcomes[0]).toMatchObject({
1620 _tag: "Importable",
1621 thread: { providerSessionId: "copied-session" },
1622 });
1623 }),
1624 );
1625
1626 it.effect("streams large transcripts across providers without hiding projects", () =>
1627 Effect.gen(function* () {
1628 const path = yield* Path.Path;
1629 const fileSystem = yield* FileSystem.FileSystem;
1630 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
1631 yield* TestClock.setTime(nowMs);
1632 const claudeHomePath = yield* makeTempDir("t3code-budget-claude-");
1633 const codexHomePath = yield* makeTempDir("t3code-budget-codex-");
1634 const workspace = yield* makeTempDir("t3code-budget-workspace-");
1635 const transcriptPaths = new Set<string>();
1636 for (const [index, source] of [
1637 "codex",
1638 "claudeAgent",
1639 "codex",
1640 "claudeAgent",
1641 "codex",
1642 ].entries()) {
1643 const sessionId = `budget-session-${index}`;
1644 const filePath =
1645 source === "codex"
1646 ? path.join(
1647 codexHomePath,
1648 "sessions",
1649 "2026",
1650 "08",
1651 "24",
1652 `rollout-${sessionId}.jsonl`,
1653 )
1654 : path.join(claudeHomePath, "projects", "selected", `${sessionId}.jsonl`);
1655 const contents =
1656 source === "codex"
1657 ? [
1658 encodeTranscriptRecord({
1659 type: "session_meta",
1660 payload: { id: sessionId, cwd: workspace },
1661 }),
1662 encodeTranscriptRecord({
1663 type: "event_msg",
1664 payload: { type: "user_message", message: "Imported prompt" },
1665 }),
1666 ].join("\n")
1667 : encodeTranscriptRecord({
1668 type: "user",
1669 cwd: workspace,
1670 sessionId,
1671 message: { content: "Imported prompt" },
1672 });
1673 transcriptPaths.add(filePath);
1674 yield* writeTranscript({
1675 filePath,
1676 contents: `${contents}\n`.padEnd(16 * 1024 * 1024, " "),
1677 mtimeMs: nowMs - index * 1_000,
1678 });
1679 }
1680
1681 const opens = new Map<string, number>();
1682 let fullReadBytes = 0;
1683 const trackedFileSystem = FileSystem.FileSystem.of({
1684 ...fileSystem,
1685 open: (filePath, options) => {
1686 const count = (opens.get(filePath) ?? 0) + 1;
1687 opens.set(filePath, count);
1688 return fileSystem.open(filePath, options).pipe(
1689 Effect.map((file) =>
1690 !transcriptPaths.has(filePath) || count === 1
1691 ? file
1692 : {
1693 ...file,
1694 stat: file.stat,
1695 readAlloc: (size: number) =>
1696 file.readAlloc(size).pipe(
1697 Effect.tap((chunk) =>
1698 Effect.sync(() => {
1699 if (chunk._tag === "Some") fullReadBytes += chunk.value.byteLength;
1700 }),
1701 ),
1702 ),
1703 },
1704 ),
1705 );
1706 },
1707 });
1708 const outcomes = yield* Effect.gen(function* () {
1709 const scanner = yield* AgentSessionScanner.AgentSessionScanner;
1710 const scan = yield* scanner.scan;
1711 expect(scan.candidates[0]?.threadCount).toBe(5);
1712 return yield* scanner.recentThreads(workspace).pipe(Stream.runCollect);
1713 }).pipe(
1714 Effect.provide(layerScannerTest({ claudeHomePath, codexHomePath })),
1715 Effect.provideService(FileSystem.FileSystem, trackedFileSystem),
1716 );
1717
1718 expect(outcomes.map((outcome) => outcome._tag)).toEqual([
1719 "Importable",
1720 "Importable",
1721 "Importable",
1722 "Importable",
1723 "Importable",
1724 ]);
1725 expect(fullReadBytes).toBe(80 * 1024 * 1024);
1726 }),
1727 );
1728
1729 it.effect("skips excessive records without blocking an older valid transcript", () =>
1730 Effect.gen(function* () {
1731 const path = yield* Path.Path;
1732 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
1733 yield* TestClock.setTime(nowMs);
1734 const claudeHomePath = yield* makeTempDir("t3code-record-budget-claude-");
1735 const codexHomePath = yield* makeTempDir("t3code-record-budget-codex-");
1736 const workspace = yield* makeTempDir("t3code-record-budget-workspace-");
1737 for (const [sessionId, padding, mtimeMs] of [
1738 ["excessive", "\n".repeat(100_001), nowMs],
1739 ["older", "", nowMs - 1_000],
1740 ] as const) {
1741 yield* writeTranscript({
1742 filePath: path.join(
1743 codexHomePath,
1744 "sessions",
1745 "2026",
1746 "08",
1747 "24",
1748 `rollout-${sessionId}.jsonl`,
1749 ),
1750 contents:
1751 [
1752 encodeTranscriptRecord({
1753 type: "session_meta",
1754 payload: { id: sessionId, cwd: workspace },
1755 }),
1756 encodeTranscriptRecord({
1757 type: "event_msg",
1758 payload: { type: "user_message", message: "Imported prompt" },
1759 }),
1760 ].join("\n") + padding,
1761 mtimeMs,
1762 });
1763 }
1764 const outcomes = yield* runRecentThreadOutcomes({
1765 claudeHomePath,
1766 codexHomePath,
1767 workspaceRoot: workspace,
1768 });
1769 expect(outcomes.map((outcome) => outcome._tag)).toEqual(["Skipped", "Importable"]);
1770 expect(outcomes[1]).toMatchObject({ thread: { providerSessionId: "older" } });
1771 }),
1772 );
1773
1774 for (const source of ["claudeAgent", "codex"] as const) {
1775 for (const replacement of [
1776 "same root",
1777 "other root",
1778 "symlink alias",
1779 "other then same",
1780 ] as const) {
1781 it.effect.skipIf(replacement === "symlink alias" && !symlinksSupported)(
1782 `rechecks ${source} snapshot cwd after replacement with ${replacement}`,
1783 () =>
1784 Effect.gen(function* () {
1785 const path = yield* Path.Path;
1786 const fileSystem = yield* FileSystem.FileSystem;
1787 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
1788 yield* TestClock.setTime(nowMs);
1789 const fixture = yield* makeTempDir("t3code-replaced-cwd-");
1790 const workspace = path.join(fixture, "original");
1791 const otherWorkspace = path.join(fixture, "other");
1792 const alias = path.join(fixture, "alias");
1793 const claudeHomePath = path.join(fixture, "claude");
1794 const codexHomePath = path.join(fixture, "codex");
1795 yield* fileSystem.makeDirectory(workspace);
1796 yield* fileSystem.makeDirectory(otherWorkspace);
1797 if (replacement === "symlink alias") yield* fileSystem.symlink(workspace, alias);
1798 const filePath =
1799 source === "codex"
1800 ? path.join(
1801 codexHomePath,
1802 "sessions",
1803 "2026",
1804 "08",
1805 "24",
1806 "rollout-replaced.jsonl",
1807 )
1808 : path.join(claudeHomePath, "projects", "p", "replaced.jsonl");
1809 const makeContents = (cwd: string, text: string, laterCwd?: string) =>
1810 [
1811 ...(source === "codex"
1812 ? [
1813 { type: "session_meta", payload: { id: "replacement-session", cwd } },
1814 { type: "event_msg", payload: { type: "user_message", message: text } },
1815 ]
1816 : [
1817 {
1818 type: "user",
1819 cwd,
1820 sessionId: "replacement-session",
1821 message: { content: text },
1822 },
1823 ]),
1824 ...(laterCwd === undefined ? [] : [{ cwd: laterCwd }]),
1825 ]
1826 .map((record) => encodeTranscriptRecord(record))
1827 .join("\n");
1828 yield* writeTranscript({
1829 filePath,
1830 contents: makeContents(workspace, "Original prompt"),
1831 mtimeMs: nowMs,
1832 });
1833
1834 yield* Effect.gen(function* () {
1835 const scanner = yield* AgentSessionScanner.AgentSessionScanner;
1836 const scan = yield* scanner.scan;
1837 expect(scan.candidates.map((candidate) => candidate.path)).toEqual([workspace]);
1838 const replacementCwd =
1839 replacement === "symlink alias"
1840 ? alias
1841 : replacement === "same root"
1842 ? workspace
1843 : otherWorkspace;
1844 yield* fileSystem.remove(filePath);
1845 yield* writeTranscript({
1846 filePath,
1847 contents: makeContents(
1848 replacementCwd,
1849 "Replacement prompt",
1850 replacement === "other then same" ? workspace : undefined,
1851 ),
1852 mtimeMs: nowMs,
1853 });
1854 const outcomes = yield* scanner.recentThreads(workspace).pipe(Stream.runCollect);
1855 if (replacement === "same root" || replacement === "symlink alias") {
1856 expect(outcomes).toHaveLength(1);
1857 expect(outcomes[0]).toMatchObject({
1858 _tag: "Importable",
1859 thread: { messages: [{ text: "Replacement prompt" }] },
1860 });
1861 } else {
1862 expect(outcomes).toEqual([{ _tag: "Skipped" }]);
1863 }
1864 }).pipe(Effect.provide(layerScannerTest({ claudeHomePath, codexHomePath })));
1865 }),
1866 );
1867 }
1868 }
1869
1870 it.effect("checks file identity and provider before skipping completed history", () =>
1871 Effect.gen(function* () {
1872 const path = yield* Path.Path;
1873 const fileSystem = yield* FileSystem.FileSystem;
1874 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
1875 yield* TestClock.setTime(nowMs);
1876 const claudeHomePath = yield* makeTempDir("t3code-completed-claude-");
1877 const codexHomePath = yield* makeTempDir("t3code-completed-codex-");
1878 const workspace = yield* makeTempDir("t3code-completed-workspace-");
1879 const filePath = path.join(
1880 codexHomePath,
1881 "sessions",
1882 "2026",
1883 "08",
1884 "24",
1885 "rollout-replaced.jsonl",
1886 );
1887 const contents = (sessionId: string) =>
1888 [
1889 encodeTranscriptRecord({
1890 type: "session_meta",
1891 payload: { id: sessionId, cwd: workspace },
1892 }),
1893 encodeTranscriptRecord({
1894 type: "event_msg",
1895 payload: { type: "user_message", message: "Imported prompt" },
1896 }),
1897 ].join("\n");
1898 yield* writeTranscript({
1899 filePath,
1900 contents: contents("original-session"),
1901 mtimeMs: nowMs,
1902 });
1903
1904 yield* Effect.gen(function* () {
1905 const scanner = yield* AgentSessionScanner.AgentSessionScanner;
1906 const initial = yield* scanner.recentThreads(workspace).pipe(Stream.runCollect);
1907 const imported = initial[0];
1908 expect(imported?._tag).toBe("Importable");
1909 if (imported?._tag !== "Importable") return;
1910 const completed = yield* scanner
1911 .recentThreads(workspace, [imported.source])
1912 .pipe(Stream.runCollect);
1913 expect(completed[0]?._tag).toBe("AlreadyImported");
1914 const wrongProvider = yield* scanner
1915 .recentThreads(workspace, [{ ...imported.source, provider: "claudeAgent" }])
1916 .pipe(Stream.runCollect);
1917 expect(wrongProvider[0]?._tag).toBe("Importable");
1918
1919 // Keep the old inode allocated while replacing the path with an equal-size file.
1920 yield* fileSystem.open(filePath);
1921 yield* fileSystem.remove(filePath);
1922 yield* writeTranscript({
1923 filePath,
1924 contents: contents("replaced-session"),
1925 mtimeMs: nowMs,
1926 });
1927 const replaced = yield* scanner
1928 .recentThreads(workspace, [imported.source])
1929 .pipe(Stream.runCollect);
1930 expect(replaced[0]).toMatchObject({
1931 _tag: "Importable",
1932 thread: { providerSessionId: "replaced-session" },
1933 source: { size: imported.source.size, mtimeMs: imported.source.mtimeMs },
1934 });
1935 }).pipe(Effect.provide(layerScannerTest({ claudeHomePath, codexHomePath })));
1936 }),
1937 );
1938
1939 it.effect("imports visible history from a transcript with an oversized tool record", () =>
1940 Effect.gen(function* () {
1941 const path = yield* Path.Path;
1942 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
1943 yield* TestClock.setTime(nowMs);
1944 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
1945 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
1946 const workspace = yield* makeTempDir("t3code-workspace-");
1947 const transcript = `${[
1948 encodeTranscriptRecord({
1949 type: "session_meta",
1950 payload: { id: "large-session", cwd: workspace },
1951 }),
1952 encodeTranscriptRecord({
1953 type: "event_msg",
1954 payload: { type: "user_message", message: "Import this large session" },
1955 }),
1956 ].join("\n")}\n${encodeTranscriptRecord({ type: "tool_result", data: "" }).padEnd(
1957 16 * 1024 * 1024 + 1,
1958 " ",
1959 )}`;
1960 yield* writeTranscript({
1961 filePath: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-large.jsonl"),
1962 contents: transcript,
1963 mtimeMs: nowMs,
1964 });
1965
1966 const outcomes = yield* runRecentThreadOutcomes({
1967 claudeHomePath,
1968 codexHomePath,
1969 workspaceRoot: workspace,
1970 });
1971
1972 expect(outcomes).toMatchObject([
1973 {
1974 _tag: "Importable",
1975 thread: {
1976 providerSessionId: "large-session",
1977 messages: [{ role: "user", text: "Import this large session" }],
1978 },
1979 },
1980 ]);
1981 }),
1982 );
1983
1984 it.effect("reports stat, read, and parse failures as skipped", () =>
1985 Effect.gen(function* () {
1986 const path = yield* Path.Path;
1987 const fileSystem = yield* FileSystem.FileSystem;
1988 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
1989 yield* TestClock.setTime(nowMs);
1990 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
1991 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
1992 const workspace = yield* makeTempDir("t3code-workspace-");
1993 const missingPath = path.join(codexHomePath, "missing.jsonl");
1994 const transcriptPaths = {
1995 stat: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-stat.jsonl"),
1996 read: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-read.jsonl"),
1997 parse: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-parse.jsonl"),
1998 };
1999 const transcriptContents = (sessionId: string) =>
2000 [
2001 encodeTranscriptRecord({
2002 type: "session_meta",
2003 payload: { id: sessionId, cwd: workspace },
2004 }),
2005 encodeTranscriptRecord({
2006 type: "event_msg",
2007 payload: { type: "user_message", message: "Import this session" },
2008 }),
2009 ].join("\n");
2010
2011 yield* writeTranscript({
2012 filePath: transcriptPaths.stat,
2013 contents: transcriptContents("stat-session"),
2014 mtimeMs: nowMs,
2015 });
2016 yield* writeTranscript({
2017 filePath: transcriptPaths.read,
2018 contents: transcriptContents("read-session"),
2019 mtimeMs: nowMs,
2020 });
2021 yield* writeTranscript({
2022 filePath: transcriptPaths.parse,
2023 contents: encodeTranscriptRecord({
2024 type: "session_meta",
2025 payload: { id: "parse-session", cwd: workspace },
2026 }),
2027 mtimeMs: nowMs,
2028 });
2029
2030 let statCount = 0;
2031 let readOpenCount = 0;
2032 const simulatedFileSystem = FileSystem.FileSystem.of({
2033 ...fileSystem,
2034 stat: (filePath) => {
2035 if (filePath !== transcriptPaths.stat) return fileSystem.stat(filePath);
2036 statCount += 1;
2037 return fileSystem.stat(statCount === 1 ? filePath : missingPath);
2038 },
2039 open: (filePath, options) => {
2040 if (filePath !== transcriptPaths.read) return fileSystem.open(filePath, options);
2041 readOpenCount += 1;
2042 return fileSystem.open(readOpenCount === 1 ? filePath : missingPath, options);
2043 },
2044 });
2045
2046 const outcomes = yield* runRecentThreadOutcomes({
2047 claudeHomePath,
2048 codexHomePath,
2049 workspaceRoot: workspace,
2050 }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem));
2051
2052 expect(outcomes).toEqual([{ _tag: "Skipped" }, { _tag: "Skipped" }, { _tag: "Skipped" }]);
2053 }),
2054 );
2055
2056 it.effect("does not reopen a transcript that becomes a non-file after discovery", () =>
2057 Effect.gen(function* () {
2058 const path = yield* Path.Path;
2059 const fileSystem = yield* FileSystem.FileSystem;
2060 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
2061 yield* TestClock.setTime(nowMs);
2062 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
2063 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
2064 const workspace = yield* makeTempDir("t3code-workspace-");
2065 const nonFilePath = yield* makeTempDir("t3code-non-file-");
2066 const transcriptPath = path.join(
2067 codexHomePath,
2068 "sessions",
2069 "2026",
2070 "08",
2071 "24",
2072 "rollout-changed.jsonl",
2073 );
2074 yield* writeTranscript({
2075 filePath: transcriptPath,
2076 contents: [
2077 encodeTranscriptRecord({
2078 type: "session_meta",
2079 payload: { id: "changed-session", cwd: workspace },
2080 }),
2081 encodeTranscriptRecord({
2082 type: "event_msg",
2083 payload: { type: "user_message", message: "Do not import this session" },
2084 }),
2085 ].join("\n"),
2086 mtimeMs: nowMs,
2087 });
2088
2089 let transcriptStatCount = 0;
2090 let transcriptOpenCount = 0;
2091 const simulatedFileSystem = FileSystem.FileSystem.of({
2092 ...fileSystem,
2093 stat: (filePath) => {
2094 if (filePath !== transcriptPath) return fileSystem.stat(filePath);
2095 transcriptStatCount += 1;
2096 return fileSystem.stat(transcriptStatCount === 1 ? transcriptPath : nonFilePath);
2097 },
2098 open: (filePath, options) => {
2099 if (filePath === transcriptPath) transcriptOpenCount += 1;
2100 return fileSystem.open(filePath, options);
2101 },
2102 });
2103
2104 const outcomes = yield* runRecentThreadOutcomes({
2105 claudeHomePath,
2106 codexHomePath,
2107 workspaceRoot: workspace,
2108 }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem));
2109
2110 expect(transcriptStatCount).toBe(2);
2111 expect(transcriptOpenCount).toBe(1);
2112 expect(outcomes).toEqual([{ _tag: "Skipped" }]);
2113 }),
2114 );
2115
2116 it.effect("does not import a transcript dated after the current time", () =>
2117 Effect.gen(function* () {
2118 const path = yield* Path.Path;
2119 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
2120 yield* TestClock.setTime(nowMs);
2121 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
2122 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
2123 const workspace = yield* makeTempDir("t3code-workspace-");
2124 yield* writeTranscript({
2125 filePath: path.join(
2126 codexHomePath,
2127 "sessions",
2128 "2026",
2129 "08",
2130 "24",
2131 "rollout-future.jsonl",
2132 ),
2133 contents: [
2134 encodeTranscriptRecord({
2135 type: "session_meta",
2136 payload: { id: "future-session", cwd: workspace },
2137 }),
2138 encodeTranscriptRecord({
2139 type: "event_msg",
2140 payload: { type: "user_message", message: "Future work" },
2141 }),
2142 ].join("\n"),
2143 // Node's BigInt stat (which the Effect file system now uses) floors
2144 // sub-millisecond precision, so a one-millisecond offset can round
2145 // back to `nowMs`; use a full second to stay clear of the clock.
2146 mtimeMs: nowMs + 1_000,
2147 });
2148
2149 const outcomes = yield* runRecentThreadOutcomes({
2150 claudeHomePath,
2151 codexHomePath,
2152 workspaceRoot: workspace,
2153 });
2154
2155 expect(outcomes).toEqual([]);
2156 }),
2157 );
2158
2159 it.effect("skips growth during reading without exceeding the reserved bytes", () =>
2160 Effect.gen(function* () {
2161 const path = yield* Path.Path;
2162 const fileSystem = yield* FileSystem.FileSystem;
2163 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
2164 yield* TestClock.setTime(nowMs);
2165 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
2166 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
2167 const workspace = yield* makeTempDir("t3code-workspace-");
2168 const transcriptPath = path.join(
2169 codexHomePath,
2170 "sessions",
2171 "2026",
2172 "08",
2173 "24",
2174 "rollout-growing.jsonl",
2175 );
2176 const contents = [
2177 encodeTranscriptRecord({
2178 type: "session_meta",
2179 payload: { id: "growing-session", cwd: workspace },
2180 }),
2181 encodeTranscriptRecord({
2182 type: "event_msg",
2183 payload: { type: "user_message", message: "Do not import a changing file" },
2184 }),
2185 ].join("\n");
2186 yield* writeTranscript({ filePath: transcriptPath, contents, mtimeMs: nowMs });
2187 let transcriptOpenCount = 0;
2188 let fullReadBytes = 0;
2189 let grew = false;
2190 const simulatedFileSystem = FileSystem.FileSystem.of({
2191 ...fileSystem,
2192 open: (filePath, options) => {
2193 if (filePath !== transcriptPath) return fileSystem.open(filePath, options);
2194 transcriptOpenCount += 1;
2195 if (transcriptOpenCount === 1) return fileSystem.open(filePath, options);
2196 return fileSystem.open(filePath, options).pipe(
2197 Effect.map((file) => ({
2198 ...file,
2199 stat: file.stat,
2200 readAlloc: (size: number) =>
2201 file.readAlloc(size).pipe(
2202 Effect.tap((chunk) =>
2203 Effect.gen(function* () {
2204 if (chunk._tag === "None") return;
2205 fullReadBytes += chunk.value.byteLength;
2206 if (!grew) {
2207 grew = true;
2208 yield* fileSystem.writeFileString(filePath, `${contents}\nchanged`);
2209 }
2210 }),
2211 ),
2212 ),
2213 })),
2214 );
2215 },
2216 });
2217
2218 const outcomes = yield* runRecentThreadOutcomes({
2219 claudeHomePath,
2220 codexHomePath,
2221 workspaceRoot: workspace,
2222 }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem));
2223
2224 expect(transcriptOpenCount).toBe(2);
2225 expect(fullReadBytes).toBe(new TextEncoder().encode(contents).byteLength);
2226 expect(outcomes).toEqual([{ _tag: "Skipped" }]);
2227 }),
2228 );
2229
2230 it.effect("skips a transcript that shrinks after its size check", () =>
2231 Effect.gen(function* () {
2232 const path = yield* Path.Path;
2233 const fileSystem = yield* FileSystem.FileSystem;
2234 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
2235 yield* TestClock.setTime(nowMs);
2236 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
2237 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
2238 const workspace = yield* makeTempDir("t3code-workspace-");
2239 const transcriptPath = path.join(
2240 codexHomePath,
2241 "sessions",
2242 "2026",
2243 "08",
2244 "24",
2245 "rollout-shrinking.jsonl",
2246 );
2247 const shrunkPath = path.join(codexHomePath, "shrunk.jsonl");
2248 const contents = [
2249 encodeTranscriptRecord({
2250 type: "session_meta",
2251 payload: { id: "shrinking-session", cwd: workspace },
2252 }),
2253 encodeTranscriptRecord({
2254 type: "event_msg",
2255 payload: { type: "user_message", message: "Do not import a changing file" },
2256 }),
2257 ].join("\n");
2258 yield* writeTranscript({
2259 filePath: transcriptPath,
2260 contents: `${contents}\n${"padding".repeat(100)}`,
2261 mtimeMs: nowMs,
2262 });
2263 yield* writeTranscript({ filePath: shrunkPath, contents, mtimeMs: nowMs });
2264
2265 let transcriptOpenCount = 0;
2266 const simulatedFileSystem = FileSystem.FileSystem.of({
2267 ...fileSystem,
2268 open: (filePath, options) => {
2269 if (filePath !== transcriptPath) return fileSystem.open(filePath, options);
2270 transcriptOpenCount += 1;
2271 return fileSystem.open(
2272 transcriptOpenCount === 1 ? transcriptPath : shrunkPath,
2273 options,
2274 );
2275 },
2276 });
2277
2278 const outcomes = yield* runRecentThreadOutcomes({
2279 claudeHomePath,
2280 codexHomePath,
2281 workspaceRoot: workspace,
2282 }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem));
2283
2284 expect(transcriptOpenCount).toBe(2);
2285 expect(outcomes).toEqual([{ _tag: "Skipped" }]);
2286 }),
2287 );
2288
2289 it.effect("does not read the second transcript when the consumer takes one thread", () =>
2290 Effect.gen(function* () {
2291 const path = yield* Path.Path;
2292 const fileSystem = yield* FileSystem.FileSystem;
2293 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
2294 yield* TestClock.setTime(nowMs);
2295 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
2296 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
2297 const workspace = yield* makeTempDir("t3code-workspace-");
2298 const makeCodexTranscript = (sessionId: string, text: string) =>
2299 [
2300 encodeTranscriptRecord({
2301 type: "session_meta",
2302 payload: { id: sessionId, cwd: workspace },
2303 }),
2304 encodeTranscriptRecord({
2305 type: "event_msg",
2306 payload: { type: "user_message", message: text },
2307 }),
2308 ].join("\n");
2309 const olderPath = path.join(
2310 codexHomePath,
2311 "sessions",
2312 "2026",
2313 "08",
2314 "23",
2315 "rollout-older.jsonl",
2316 );
2317 const newerPath = path.join(
2318 codexHomePath,
2319 "sessions",
2320 "2026",
2321 "08",
2322 "24",
2323 "rollout-newer.jsonl",
2324 );
2325 yield* writeTranscript({
2326 filePath: olderPath,
2327 contents: makeCodexTranscript("older-session", "Older prompt"),
2328 mtimeMs: nowMs - 1_000,
2329 });
2330 yield* writeTranscript({
2331 filePath: newerPath,
2332 contents: makeCodexTranscript("newer-session", "Newer prompt"),
2333 mtimeMs: nowMs,
2334 });
2335
2336 const openCounts = new Map<string, number>();
2337 const contentReads: Array<string> = [];
2338 const trackedPaths = new Set([olderPath, newerPath]);
2339 const simulatedFileSystem = FileSystem.FileSystem.of({
2340 ...fileSystem,
2341 open: (filePath, options) => {
2342 if (trackedPaths.has(filePath)) {
2343 const count = (openCounts.get(filePath) ?? 0) + 1;
2344 openCounts.set(filePath, count);
2345 if (count === 2) contentReads.push(filePath);
2346 }
2347 return fileSystem.open(filePath, options);
2348 },
2349 });
2350
2351 const threads = yield* Effect.gen(function* () {
2352 const scanner = yield* AgentSessionScanner.AgentSessionScanner;
2353 return yield* scanner.recentThreads(workspace).pipe(
2354 Stream.take(1),
2355 Stream.runCollect,
2356 Effect.map((items) => Array.from(items)),
2357 );
2358 }).pipe(
2359 Effect.provide(layerScannerTest({ claudeHomePath, codexHomePath })),
2360 Effect.provideService(FileSystem.FileSystem, simulatedFileSystem),
2361 );
2362
2363 expect(
2364 threads.flatMap((outcome) =>
2365 outcome._tag === "Importable" ? [outcome.thread.providerSessionId] : [],
2366 ),
2367 ).toEqual(["newer-session"]);
2368 expect(contentReads).toEqual([newerPath]);
2369 expect(openCounts.get(olderPath)).toBe(1);
2370 }),
2371 );
2372
2373 it.effect("does not import sessions from a T3-managed worktree", () =>
2374 Effect.gen(function* () {
2375 const path = yield* Path.Path;
2376 const fileSystem = yield* FileSystem.FileSystem;
2377 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
2378 yield* TestClock.setTime(nowMs);
2379 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
2380 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
2381 const configBaseDir = yield* makeTempDir("t3code-scanner-base-");
2382 const workspace = path.join(configBaseDir, "worktrees", "t3code", "managed-worktree");
2383 yield* fileSystem.makeDirectory(workspace, { recursive: true });
2384
2385 yield* writeTranscript({
2386 filePath: path.join(
2387 codexHomePath,
2388 "sessions",
2389 "2026",
2390 "08",
2391 "24",
2392 "rollout-managed.jsonl",
2393 ),
2394 contents: [
2395 encodeTranscriptRecord({
2396 type: "session_meta",
2397 payload: { id: "managed-session", cwd: workspace },
2398 }),
2399 encodeTranscriptRecord({
2400 type: "event_msg",
2401 payload: { type: "user_message", message: "Do not import this session" },
2402 }),
2403 ].join("\n"),
2404 mtimeMs: nowMs,
2405 });
2406
2407 const threads = yield* runRecentThreads({
2408 claudeHomePath,
2409 codexHomePath,
2410 configBaseDir,
2411 workspaceRoot: workspace,
2412 });
2413
2414 expect(threads).toEqual([]);
2415 }),
2416 );
2417
2418 it.effect("uses one deterministic provider instance for a shared session home", () =>
2419 Effect.gen(function* () {
2420 const path = yield* Path.Path;
2421 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
2422 yield* TestClock.setTime(nowMs);
2423 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
2424 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
2425 const sharedHome = yield* makeTempDir("t3code-codex-shared-");
2426 const workspace = yield* makeTempDir("t3code-workspace-");
2427
2428 yield* writeTranscript({
2429 filePath: path.join(sharedHome, "sessions", "2026", "08", "24", "rollout-shared.jsonl"),
2430 contents: [
2431 encodeTranscriptRecord({
2432 type: "session_meta",
2433 payload: { id: "shared-session", cwd: workspace },
2434 }),
2435 encodeTranscriptRecord({
2436 type: "event_msg",
2437 payload: { type: "user_message", message: "Use the shared session" },
2438 }),
2439 ].join("\n"),
2440 mtimeMs: nowMs,
2441 });
2442
2443 const threads = yield* runRecentThreads({
2444 claudeHomePath,
2445 codexHomePath,
2446 workspaceRoot: workspace,
2447 providerInstances: {
2448 [ProviderInstanceId.make("codex")]: {
2449 driver: ProviderDriverKind.make("codex"),
2450 config: { homePath: sharedHome },
2451 },
2452 [ProviderInstanceId.make("codex-personal")]: {
2453 driver: ProviderDriverKind.make("codex"),
2454 config: { homePath: sharedHome },
2455 },
2456 [ProviderInstanceId.make("codex-work")]: {
2457 driver: ProviderDriverKind.make("codex"),
2458 config: { homePath: sharedHome },
2459 },
2460 },
2461 });
2462
2463 expect(threads.map((thread) => thread.providerInstanceId)).toEqual(["codex"]);
2464 }),
2465 );
2466
2467 it.effect("uses configured order when custom instances share a session home", () =>
2468 Effect.gen(function* () {
2469 const path = yield* Path.Path;
2470 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
2471 yield* TestClock.setTime(nowMs);
2472 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
2473 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
2474 const sharedHome = yield* makeTempDir("t3code-codex-shared-");
2475 const workspace = yield* makeTempDir("t3code-workspace-");
2476
2477 yield* writeTranscript({
2478 filePath: path.join(sharedHome, "sessions", "2026", "08", "24", "rollout-shared.jsonl"),
2479 contents: [
2480 encodeTranscriptRecord({
2481 type: "session_meta",
2482 payload: { id: "shared-session", cwd: workspace },
2483 }),
2484 encodeTranscriptRecord({
2485 type: "event_msg",
2486 payload: { type: "user_message", message: "Use the first account" },
2487 }),
2488 ].join("\n"),
2489 mtimeMs: nowMs,
2490 });
2491
2492 const threads = yield* runRecentThreads({
2493 claudeHomePath,
2494 codexHomePath,
2495 workspaceRoot: workspace,
2496 providerInstances: {
2497 [ProviderInstanceId.make("codex-work")]: {
2498 driver: ProviderDriverKind.make("codex"),
2499 config: { homePath: sharedHome },
2500 },
2501 [ProviderInstanceId.make("codex-personal")]: {
2502 driver: ProviderDriverKind.make("codex"),
2503 config: { homePath: sharedHome },
2504 },
2505 },
2506 });
2507
2508 expect(threads.map((thread) => thread.providerInstanceId)).toEqual(["codex-work"]);
2509 }),
2510 );
2511
2512 it.effect("keeps a second account when the first has 5000 newer files", () =>
2513 Effect.gen(function* () {
2514 const path = yield* Path.Path;
2515 const fileSystem = yield* FileSystem.FileSystem;
2516 const nowMs = Date.parse("2026-08-24T12:00:00.000Z");
2517 yield* TestClock.setTime(nowMs);
2518 const claudeHomePath = yield* makeTempDir("t3code-claude-home-");
2519 const codexHomePath = yield* makeTempDir("t3code-codex-home-");
2520 const oldWorkspace = yield* makeTempDir("t3code-workspace-old-");
2521 const recentWorkspace = yield* makeTempDir("t3code-workspace-recent-");
2522 const recentHome = yield* makeTempDir("t3code-claude-recent-home-");
2523 const oldDirectory = path.join(claudeHomePath, "projects", "-aaa-old");
2524 const oldTranscript = path.join(oldDirectory, "old.jsonl");
2525 const recentDirectory = path.join(recentHome, "projects", "-zzz-recent");
2526
2527 yield* writeTranscript({
2528 filePath: oldTranscript,
2529 contents: encodeTranscriptRecord({
2530 type: "user",
2531 cwd: oldWorkspace,
2532 sessionId: "old-session",
2533 message: { role: "user", content: "Old work" },
2534 }),
2535 mtimeMs: nowMs,
2536 });
2537 yield* writeTranscript({
2538 filePath: path.join(recentDirectory, "recent.jsonl"),
2539 contents: encodeTranscriptRecord({
2540 type: "user",
2541 cwd: recentWorkspace,
2542 sessionId: "recent-session",
2543 message: { role: "user", content: "Recent work" },
2544 }),
2545 mtimeMs: nowMs - 1_000,
2546 });
2547
2548 const simulatedOldTranscripts = Array.from(
2549 { length: 5_000 },
2550 (_, index) => `old-${index}.jsonl`,
2551 );
2552 const resolveTranscript = (filePath: string) =>
2553 path.dirname(filePath) === oldDirectory && path.basename(filePath).startsWith("old-")
2554 ? oldTranscript
2555 : filePath;
2556 const simulatedFileSystem = FileSystem.FileSystem.of({
2557 ...fileSystem,
2558 readDirectory: (directory, options) =>
2559 directory === oldDirectory
2560 ? Effect.succeed(simulatedOldTranscripts)
2561 : fileSystem.readDirectory(directory, options),
2562 stat: (filePath) => fileSystem.stat(resolveTranscript(filePath)),
2563 open: (filePath, options) => fileSystem.open(resolveTranscript(filePath), options),
2564 });
2565
2566 const input = {
2567 claudeHomePath,
2568 codexHomePath,
2569 providerInstances: {
2570 [ProviderInstanceId.make("claude-work")]: {
2571 driver: ProviderDriverKind.make("claudeAgent"),
2572 config: { homePath: recentHome },
2573 },
2574 },
2575 };
2576 const threads = yield* Effect.gen(function* () {
2577 const scanner = yield* AgentSessionScanner.AgentSessionScanner;
2578 const scan = yield* scanner.scan;
2579 expect(scan.truncated).toBe(true);
2580 return yield* scanner.recentThreads(recentWorkspace).pipe(Stream.runCollect);
2581 }).pipe(
2582 Effect.provide(layerScannerTest(input)),
2583 Effect.provideService(FileSystem.FileSystem, simulatedFileSystem),
2584 );
2585
2586 expect(
2587 threads.flatMap((outcome) =>
2588 outcome._tag === "Importable" ? [outcome.thread.providerSessionId] : [],
2589 ),
2590 ).toEqual(["recent-session"]);
2591 }),
2592 );
2593 });
2594});
2595
2596describe("parseAgentSessionTranscript", () => {
2597 it.each([false, true])(
2598 "handles the exact record limit and an interior blank overflow=%s",
2599 (overflow) => {
2600 const thread = AgentSessionScanner.parseAgentSessionTranscript({
2601 contents: makeRecordLimitTranscript("/project", overflow),
2602 source: "codex",
2603 providerInstanceId: ProviderInstanceId.make("codex"),
2604 fallbackSessionId: "unused",
2605 lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
2606 });
2607 if (overflow) expect(thread).toBeNull();
2608 else expect(thread?.messages.map((message) => message.text)).toEqual(["First prompt"]);
2609 },
2610 );
2611
2612 it("keeps Claude text and titles while dropping malformed and tool records", () => {
2613 const thread = AgentSessionScanner.parseAgentSessionTranscript({
2614 contents: [
2615 "not valid json",
2616 JSON.stringify({ type: "ai-title", aiTitle: "Fix authentication" }),
2617 JSON.stringify({
2618 type: "user",
2619 sessionId: "claude-session",
2620 isMeta: true,
2621 message: { role: "user", content: "Injected skill instructions" },
2622 }),
2623 JSON.stringify({
2624 type: "user",
2625 sessionId: "claude-session",
2626 isCompactSummary: true,
2627 message: { role: "user", content: "Injected compaction summary" },
2628 }),
2629 JSON.stringify({
2630 type: "user",
2631 sessionId: "claude-session",
2632 timestamp: "2026-08-24T10:00:00.000Z",
2633 message: { role: "user", content: [{ type: "text", text: "Fix authentication" }] },
2634 }),
2635 JSON.stringify({
2636 type: "user",
2637 sessionId: "claude-session",
2638 message: { role: "user", content: [{ type: "tool_result", text: "hidden" }] },
2639 }),
2640 JSON.stringify({
2641 type: "assistant",
2642 sessionId: "claude-session",
2643 message: {
2644 role: "assistant",
2645 model: "claude-sonnet-5",
2646 content: [{ type: "text", text: "Updated the login flow" }],
2647 },
2648 }),
2649 JSON.stringify({
2650 type: "assistant",
2651 sessionId: "claude-session",
2652 message: {
2653 role: "assistant",
2654 model: "<synthetic>",
2655 content: [{ type: "text", text: "The provider request failed" }],
2656 },
2657 }),
2658 ].join("\n"),
2659 source: "claudeAgent",
2660 providerInstanceId: ProviderInstanceId.make("claudeAgent"),
2661 fallbackSessionId: "fallback",
2662 lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
2663 });
2664
2665 expect(thread).toMatchObject({
2666 providerSessionId: "claude-session",
2667 title: "Fix authentication",
2668 model: "claude-sonnet-5",
2669 messages: [
2670 { role: "user", text: "Fix authentication" },
2671 { role: "assistant", text: "Updated the login flow" },
2672 { role: "assistant", text: "The provider request failed" },
2673 ],
2674 });
2675 });
2676
2677 it("drops injected Codex instructions while keeping the visible user event", () => {
2678 const thread = AgentSessionScanner.parseAgentSessionTranscript({
2679 contents: [
2680 JSON.stringify({ type: "session_meta", payload: { id: "codex-session" } }),
2681 JSON.stringify({
2682 type: "response_item",
2683 payload: {
2684 type: "message",
2685 role: "user",
2686 internal_chat_message_metadata_passthrough: { turn_id: "turn-1" },
2687 content: [
2688 {
2689 type: "input_text",
2690 text: "<user_instructions>\nInternal setup instructions\n</user_instructions>",
2691 },
2692 ],
2693 },
2694 }),
2695 JSON.stringify({
2696 type: "event_msg",
2697 payload: { type: "user_message", message: "Fix the actual bug" },
2698 }),
2699 JSON.stringify({
2700 type: "response_item",
2701 payload: {
2702 type: "message",
2703 role: "user",
2704 internal_chat_message_metadata_passthrough: { turn_id: "turn-1" },
2705 content: [{ type: "input_text", text: "Fix the actual bug" }],
2706 },
2707 }),
2708 JSON.stringify({
2709 type: "response_item",
2710 payload: {
2711 type: "message",
2712 role: "assistant",
2713 content: [{ type: "output_text", text: "Fixed" }],
2714 },
2715 }),
2716 ].join("\n"),
2717 source: "codex",
2718 providerInstanceId: ProviderInstanceId.make("codex"),
2719 fallbackSessionId: "fallback",
2720 lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
2721 });
2722
2723 expect(thread?.messages.map((message) => message.text)).toEqual([
2724 "Fix the actual bug",
2725 "Fixed",
2726 ]);
2727 });
2728
2729 it("keeps the canonical first prompt after long Codex transcripts are capped", () => {
2730 const canonicalPrompt = "\n Keep the canonical prompt \n";
2731 const canonicalTimestamp = "2026-08-24T10:01:00.000Z";
2732 const laterAssistantMessages = Array.from({ length: 200 }, (_, index) =>
2733 encodeTranscriptRecord({
2734 type: "response_item",
2735 timestamp: `2026-08-24T11:${String(index % 60).padStart(2, "0")}:00.000Z`,
2736 payload: {
2737 type: "message",
2738 role: "assistant",
2739 content: [{ type: "output_text", text: `Assistant message ${index}` }],
2740 },
2741 }),
2742 );
2743 const thread = AgentSessionScanner.parseAgentSessionTranscript({
2744 contents: [
2745 encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }),
2746 encodeTranscriptRecord({
2747 type: "response_item",
2748 timestamp: "2026-08-24T10:00:00.000Z",
2749 payload: {
2750 type: "message",
2751 role: "user",
2752 content: [{ type: "input_text", text: "Keep the canonical prompt" }],
2753 },
2754 }),
2755 encodeTranscriptRecord({
2756 type: "event_msg",
2757 timestamp: canonicalTimestamp,
2758 payload: { type: "user_message", message: canonicalPrompt },
2759 }),
2760 ...laterAssistantMessages,
2761 ].join("\n"),
2762 source: "codex",
2763 providerInstanceId: ProviderInstanceId.make("codex"),
2764 fallbackSessionId: "fallback",
2765 lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
2766 });
2767
2768 expect(thread?.messages).toHaveLength(200);
2769 expect(thread?.messages[0]).toMatchObject({
2770 role: "user",
2771 text: canonicalPrompt,
2772 createdAt: canonicalTimestamp,
2773 });
2774 });
2775
2776 it("restores the canonical first prompt when a later user message remains", () => {
2777 const canonicalPrompt = "\n Keep the canonical prompt \n";
2778 const canonicalTimestamp = "2026-08-24T10:01:00.000Z";
2779 const assistantMessages = Array.from({ length: 198 }, (_, index) =>
2780 encodeTranscriptRecord({
2781 type: "response_item",
2782 timestamp: `2026-08-24T11:${String(index % 60).padStart(2, "0")}:00.000Z`,
2783 payload: {
2784 type: "message",
2785 role: "assistant",
2786 content: [{ type: "output_text", text: `Assistant message ${index}` }],
2787 },
2788 }),
2789 );
2790 const thread = AgentSessionScanner.parseAgentSessionTranscript({
2791 contents: [
2792 encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }),
2793 encodeTranscriptRecord({
2794 type: "response_item",
2795 timestamp: "2026-08-24T10:00:00.000Z",
2796 payload: {
2797 type: "message",
2798 role: "user",
2799 internal_chat_message_metadata_passthrough: { turn_id: "turn-1" },
2800 content: [{ type: "input_text", text: "Keep the canonical prompt" }],
2801 },
2802 }),
2803 encodeTranscriptRecord({
2804 type: "event_msg",
2805 timestamp: canonicalTimestamp,
2806 payload: { type: "user_message", message: canonicalPrompt },
2807 }),
2808 ...assistantMessages,
2809 encodeTranscriptRecord({
2810 type: "event_msg",
2811 timestamp: "2026-08-24T11:58:30.000Z",
2812 payload: { type: "user_message", message: "Keep this later prompt" },
2813 }),
2814 encodeTranscriptRecord({
2815 type: "response_item",
2816 timestamp: "2026-08-24T11:59:00.000Z",
2817 payload: {
2818 type: "message",
2819 role: "assistant",
2820 content: [{ type: "output_text", text: "Keep this latest response" }],
2821 },
2822 }),
2823 ].join("\n"),
2824 source: "codex",
2825 providerInstanceId: ProviderInstanceId.make("codex"),
2826 fallbackSessionId: "fallback",
2827 lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
2828 });
2829
2830 expect(thread?.messages).toHaveLength(200);
2831 expect(thread?.messages[0]).toMatchObject({
2832 role: "user",
2833 text: canonicalPrompt,
2834 createdAt: canonicalTimestamp,
2835 });
2836 expect(
2837 thread?.messages.filter((message) => message.text.trim() === canonicalPrompt.trim()),
2838 ).toHaveLength(1);
2839 expect(thread?.messages.some((message) => message.text === "Keep this later prompt")).toBe(
2840 true,
2841 );
2842 expect(thread?.messages.at(-1)?.text).toBe("Keep this latest response");
2843 });
2844
2845 it("keeps mixed-format response users when turn IDs repeat after an assistant", () => {
2846 const thread = AgentSessionScanner.parseAgentSessionTranscript({
2847 contents: [
2848 encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }),
2849 encodeTranscriptRecord({
2850 type: "response_item",
2851 payload: {
2852 type: "message",
2853 role: "user",
2854 internal_chat_message_metadata_passthrough: { turn_id: "turn-older" },
2855 content: [{ type: "input_text", text: "Keep this older prompt" }],
2856 },
2857 }),
2858 encodeTranscriptRecord({
2859 type: "event_msg",
2860 payload: { type: "user_message", message: "Keep this newer prompt" },
2861 }),
2862 encodeTranscriptRecord({
2863 type: "response_item",
2864 payload: {
2865 type: "message",
2866 role: "user",
2867 internal_chat_message_metadata_passthrough: { turn_id: "turn-newer" },
2868 content: [{ type: "input_text", text: "Keep this newer prompt" }],
2869 },
2870 }),
2871 encodeTranscriptRecord({
2872 type: "response_item",
2873 payload: {
2874 type: "message",
2875 role: "assistant",
2876 content: [{ type: "output_text", text: "Ask again when needed" }],
2877 },
2878 }),
2879 encodeTranscriptRecord({
2880 type: "response_item",
2881 payload: {
2882 type: "message",
2883 role: "user",
2884 internal_chat_message_metadata_passthrough: { turn_id: "turn-newer" },
2885 content: [{ type: "input_text", text: "Keep this newer prompt" }],
2886 },
2887 }),
2888 ].join("\n"),
2889 source: "codex",
2890 providerInstanceId: ProviderInstanceId.make("codex"),
2891 fallbackSessionId: "fallback",
2892 lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
2893 });
2894
2895 expect(thread?.messages.map((message) => message.text)).toEqual([
2896 "Keep this older prompt",
2897 "Keep this newer prompt",
2898 "Ask again when needed",
2899 "Keep this newer prompt",
2900 ]);
2901 });
2902
2903 it("preserves response user text when Codex turn metadata is ambiguous", () => {
2904 const thread = AgentSessionScanner.parseAgentSessionTranscript({
2905 contents: [
2906 encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }),
2907 encodeTranscriptRecord({
2908 type: "response_item",
2909 payload: {
2910 type: "message",
2911 role: "user",
2912 internal_chat_message_metadata_passthrough: ["unexpected"],
2913 content: [{ type: "input_text", text: "Keep this legacy prompt" }],
2914 },
2915 }),
2916 encodeTranscriptRecord({
2917 type: "response_item",
2918 payload: {
2919 type: "message",
2920 role: "user",
2921 internal_chat_message_metadata_passthrough: { turn_id: " " },
2922 content: [{ type: "input_text", text: "Keep this prompt with a blank turn ID" }],
2923 },
2924 }),
2925 ].join("\n"),
2926 source: "codex",
2927 providerInstanceId: ProviderInstanceId.make("codex"),
2928 fallbackSessionId: "fallback",
2929 lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
2930 });
2931
2932 expect(thread?.messages.map((message) => message.text)).toEqual([
2933 "Keep this legacy prompt",
2934 "Keep this prompt with a blank turn ID",
2935 ]);
2936 });
2937
2938 it("uses the first valid Codex session ID when a fork copies ancestor metadata", () => {
2939 const thread = AgentSessionScanner.parseAgentSessionTranscript({
2940 contents: [
2941 encodeTranscriptRecord({
2942 type: "session_meta",
2943 payload: { id: "fork-session", forked_from_id: "parent-session" },
2944 }),
2945 encodeTranscriptRecord({
2946 type: "session_meta",
2947 payload: { id: "parent-session" },
2948 }),
2949 encodeTranscriptRecord({
2950 type: "event_msg",
2951 payload: { type: "user_message", message: "Continue in the fork" },
2952 }),
2953 ].join("\n"),
2954 source: "codex",
2955 providerInstanceId: ProviderInstanceId.make("codex"),
2956 fallbackSessionId: "fallback",
2957 lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
2958 });
2959
2960 expect(thread?.providerSessionId).toBe("fork-session");
2961 });
2962
2963 it("skips Codex transcripts without a resumable session ID", () => {
2964 const thread = AgentSessionScanner.parseAgentSessionTranscript({
2965 contents: encodeTranscriptRecord({
2966 type: "event_msg",
2967 payload: { type: "user_message", message: "This transcript has no session metadata" },
2968 }),
2969 source: "codex",
2970 providerInstanceId: ProviderInstanceId.make("codex"),
2971 fallbackSessionId: "rollout-2026-08-24T12-00-00-not-a-session-id",
2972 lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
2973 });
2974
2975 expect(thread).toBeNull();
2976 });
2977
2978 it("uses the canonical Codex event when its turn has generated response context", () => {
2979 const thread = AgentSessionScanner.parseAgentSessionTranscript({
2980 contents: [
2981 encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }),
2982 encodeTranscriptRecord({
2983 type: "response_item",
2984 payload: {
2985 type: "message",
2986 role: "user",
2987 internal_chat_message_metadata_passthrough: { turn_id: "turn-1" },
2988 content: [
2989 {
2990 type: "input_text",
2991 text: "<environment_context>\n<cwd>/tmp/project</cwd>\n<shell>zsh</shell>\n</environment_context>",
2992 },
2993 ],
2994 },
2995 }),
2996 encodeTranscriptRecord({
2997 type: "response_item",
2998 payload: {
2999 type: "message",
3000 role: "user",
3001 internal_chat_message_metadata_passthrough: { turn_id: "turn-1" },
3002 content: [
3003 {
3004 type: "input_text",
3005 text: "# AGENTS.md instructions for /tmp/project\n\n<INSTRUCTIONS>\nPrivate project rules\n</INSTRUCTIONS>",
3006 },
3007 ],
3008 },
3009 }),
3010 encodeTranscriptRecord({
3011 type: "event_msg",
3012 payload: {
3013 type: "user_message",
3014 message: "Do something here so it looks like a real project.",
3015 },
3016 }),
3017 encodeTranscriptRecord({
3018 type: "response_item",
3019 payload: {
3020 type: "message",
3021 role: "user",
3022 internal_chat_message_metadata_passthrough: { turn_id: "turn-1" },
3023 content: [
3024 {
3025 type: "input_text",
3026 text: "Do something here so it looks like a real project.",
3027 },
3028 ],
3029 },
3030 }),
3031 encodeTranscriptRecord({
3032 type: "response_item",
3033 payload: {
3034 type: "message",
3035 role: "assistant",
3036 content: [{ type: "output_text", text: "Created the project." }],
3037 },
3038 }),
3039 ].join("\n"),
3040 source: "codex",
3041 providerInstanceId: ProviderInstanceId.make("codex"),
3042 fallbackSessionId: "fallback",
3043 lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"),
3044 });
3045
3046 expect(thread?.title).toBe("Do something here so it looks like a real project.");
3047 expect(thread?.messages.map((message) => message.text)).toEqual([
3048 "Do something here so it looks like a real project.",
3049 "Created the project.",
3050 ]);
3051 });
3052
3053 it("preserves context markup in response-only Codex messages", () => {
3054 const context = "<environment_context>\n<cwd>/tmp/project</cwd>\n</environment_context>";
3055 const thread = AgentSessionScanner.parseAgentSessionTranscript({
3056 contents: [
3057 encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }),
3058 encodeTranscriptRecord({
3059 type: "response_item",
3060 payload: {
3061 type: "message",
3062 role: "user",
3063 content: [
3064 {
3065 type: "input_text",
3066 text: context,
3067 },
3068 ],
3069 },
3070 }),
3071 encodeTranscriptRecord({
3072 type: "response_item",
3073 payload: {
3074 type: "message",
3075 role: "user",
3076 content: [{ type: "input_text", text: "Initialize Git and add a README." }],
3077 },
3078 }),
3079 ].join("\n"),
3080 source: "codex",
3081 providerInstanceId: ProviderInstanceId.make("codex"),
3082 fallbackSessionId: "fallback",
3083 lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"),
3084 });
3085
3086 expect(thread?.title).toBe("<environment_context>");
3087 expect(thread?.messages.map((message) => message.text)).toEqual([
3088 context,
3089 "Initialize Git and add a README.",
3090 ]);
3091 });
3092
3093 it("preserves a canonical Codex event that starts with context markup", () => {
3094 const prompt =
3095 "<environment_context>\n<cwd>/tmp/project</cwd>\n</environment_context>\n\nCreate a useful project.";
3096 const thread = AgentSessionScanner.parseAgentSessionTranscript({
3097 contents: [
3098 encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }),
3099 encodeTranscriptRecord({
3100 type: "event_msg",
3101 payload: {
3102 type: "user_message",
3103 message: prompt,
3104 },
3105 }),
3106 ].join("\n"),
3107 source: "codex",
3108 providerInstanceId: ProviderInstanceId.make("codex"),
3109 fallbackSessionId: "fallback",
3110 lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"),
3111 });
3112
3113 expect(thread?.title).toBe("<environment_context>");
3114 expect(thread?.messages.map((message) => message.text)).toEqual([prompt]);
3115 });
3116
3117 it("preserves a Codex request heading in a canonical event", () => {
3118 const prompt = "\n ## My request for Codex:\n\nFix the visible bug";
3119 const thread = AgentSessionScanner.parseAgentSessionTranscript({
3120 contents: [
3121 encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }),
3122 encodeTranscriptRecord({
3123 type: "event_msg",
3124 payload: {
3125 type: "user_message",
3126 message: prompt,
3127 },
3128 }),
3129 ].join("\n"),
3130 source: "codex",
3131 providerInstanceId: ProviderInstanceId.make("codex"),
3132 fallbackSessionId: "fallback",
3133 lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"),
3134 });
3135
3136 expect(thread?.title).toBe("## My request for Codex:");
3137 expect(thread?.messages.map((message) => message.text)).toEqual([prompt]);
3138 });
3139
3140 it("keeps context markup quoted inside visible Codex user text", () => {
3141 const quoted =
3142 "Do not remove this example:\n<environment_context>\n<cwd>/tmp/example</cwd>\n</environment_context>";
3143 const thread = AgentSessionScanner.parseAgentSessionTranscript({
3144 contents: [
3145 encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }),
3146 encodeTranscriptRecord({
3147 type: "event_msg",
3148 payload: { type: "user_message", message: quoted },
3149 }),
3150 ].join("\n"),
3151 source: "codex",
3152 providerInstanceId: ProviderInstanceId.make("codex"),
3153 fallbackSessionId: "fallback",
3154 lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"),
3155 });
3156
3157 expect(thread?.messages.map((message) => message.text)).toEqual([quoted]);
3158 });
3159
3160 it("skips sessions without a visible user message", () => {
3161 const thread = AgentSessionScanner.parseAgentSessionTranscript({
3162 contents: JSON.stringify({
3163 type: "assistant",
3164 message: { role: "assistant", content: "Done" },
3165 }),
3166 source: "claudeAgent",
3167 providerInstanceId: ProviderInstanceId.make("claudeAgent"),
3168 fallbackSessionId: "claude-session",
3169 lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
3170 });
3171
3172 expect(thread).toBeNull();
3173 });
3174
3175 it("keeps the first prompt when later assistant output exceeds the message limit", () => {
3176 const transcript = [
3177 encodeTranscriptRecord({
3178 type: "user",
3179 sessionId: "claude-session",
3180 message: { role: "user", content: "Keep this prompt" },
3181 }),
3182 ...Array.from({ length: 250 }, (_, index) =>
3183 encodeTranscriptRecord({
3184 type: "assistant",
3185 message: { role: "assistant", content: `Assistant update ${index}` },
3186 }),
3187 ),
3188 ].join("\n");
3189
3190 const thread = AgentSessionScanner.parseAgentSessionTranscript({
3191 contents: transcript,
3192 source: "claudeAgent",
3193 providerInstanceId: ProviderInstanceId.make("claudeAgent"),
3194 fallbackSessionId: "fallback",
3195 lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"),
3196 });
3197
3198 expect(thread?.messages).toHaveLength(200);
3199 expect(thread?.messages[0]?.text).toBe("Keep this prompt");
3200 expect(thread?.messages.at(-1)?.text).toBe("Assistant update 249");
3201 });
3202});