import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import { type OrchestrationProject, ProjectId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as ServerConfig from "../config.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as AgentSessionScanner from "./AgentSessionScanner.ts"; const makeProject = (workspaceRoot: string): OrchestrationProject => ({ id: ProjectId.make("project-1"), title: "Imported", workspaceRoot, defaultModelSelection: null, scripts: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", deletedAt: null, }); /** Only `getActiveProjectByWorkspaceRoot` is exercised; the rest must not be called. */ const makeProjectionSnapshotQueryLayer = (importedWorkspaceRoots: ReadonlyArray) => Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { getCommandReadModel: () => Effect.die("unused"), getSnapshot: () => Effect.die("unused"), getShellSnapshot: () => Effect.die("unused"), getArchivedShellSnapshot: () => Effect.die("unused"), getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: (workspaceRoot) => Effect.succeed( importedWorkspaceRoots.includes(workspaceRoot) ? Option.some(makeProject(workspaceRoot)) : Option.none(), ), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.die("unused"), getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), searchThreads: () => Effect.die("unused"), }); /** * Run a scan against the given homes. Homes are temp dirs created inside the * test, so the layer is built per run rather than shared. */ const runScan = (input: { readonly claudeHomePath: string; readonly codexHomePath: string; readonly importedWorkspaceRoots?: ReadonlyArray; /** Base dir for the test ServerConfig; worktreesDir derives from it. */ readonly configBaseDir?: string; }) => Effect.gen(function* () { const scanner = yield* AgentSessionScanner.AgentSessionScanner; return yield* scanner.scan; }).pipe( Effect.provide( AgentSessionScanner.layer.pipe( Layer.provide( Layer.mergeAll( ServerSettings.layerTest({ providers: { claudeAgent: { homePath: input.claudeHomePath }, codex: { homePath: input.codexHomePath }, }, }), ServerConfig.layerTest( input.claudeHomePath, input.configBaseDir ?? { prefix: "t3code-scanner-config-" }, ), makeProjectionSnapshotQueryLayer(input.importedWorkspaceRoots ?? []), ), ), ), ), ); const makeTempDir = Effect.fn("AgentSessionScanner.test.makeTempDir")(function* (prefix: string) { const fileSystem = yield* FileSystem.FileSystem; return yield* fileSystem.makeTempDirectoryScoped({ prefix }); }); const writeTranscript = Effect.fn("AgentSessionScanner.test.writeTranscript")(function* (input: { readonly filePath: string; readonly contents: string; /** Epoch millis, so ordering assertions never depend on write timing. */ readonly mtimeMs: number; }) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; yield* fileSystem.makeDirectory(path.dirname(input.filePath), { recursive: true }); yield* fileSystem.writeFileString(input.filePath, input.contents); // Numeric utimes arguments are seconds, not milliseconds. const seconds = input.mtimeMs / 1000; yield* fileSystem.utimes(input.filePath, seconds, seconds); }); /** Claude session line: the first record carries the real `cwd`. */ const claudeSessionLine = (cwd: string) => `${JSON.stringify({ type: "user", cwd, sessionId: "s1" })}\n${JSON.stringify({ type: "assistant" })}\n`; /** Codex rollout line: session metadata is nested under `payload`. */ const codexRolloutLine = (cwd: string) => `${JSON.stringify({ timestamp: "2026-01-01T00:00:00.000Z", type: "session_meta", payload: { id: "r1", cwd } })}\n`; it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { describe("scan", () => { it.effect("reads Claude project cwds from transcripts, newest first", () => Effect.gen(function* () { const path = yield* Path.Path; const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); const codexHomePath = yield* makeTempDir("t3code-codex-home-"); const olderWorkspace = yield* makeTempDir("t3code-workspace-older-"); const newerWorkspace = yield* makeTempDir("t3code-workspace-newer-"); // Slugs are intentionally lossy; the scanner must not decode them. yield* writeTranscript({ filePath: path.join(claudeHomePath, "projects", "-slug-older", "a.jsonl"), contents: claudeSessionLine(olderWorkspace), mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), }); yield* writeTranscript({ filePath: path.join(claudeHomePath, "projects", "-slug-older", "b.jsonl"), contents: claudeSessionLine(olderWorkspace), mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), }); yield* writeTranscript({ filePath: path.join(claudeHomePath, "projects", "-slug-newer", "c.jsonl"), contents: claudeSessionLine(newerWorkspace), mtimeMs: Date.parse("2026-03-01T00:00:00.000Z"), }); const result = yield* runScan({ claudeHomePath, codexHomePath }); expect(result.candidates).toEqual([ { path: newerWorkspace, title: path.basename(newerWorkspace), sources: ["claudeAgent"], threadCount: 1, lastActiveAt: "2026-03-01T00:00:00.000Z", alreadyImported: false, }, { path: olderWorkspace, title: path.basename(olderWorkspace), sources: ["claudeAgent"], threadCount: 2, lastActiveAt: "2026-01-02T00:00:00.000Z", alreadyImported: false, }, ]); }), ); it.effect("groups Codex rollouts by cwd across date directories", () => Effect.gen(function* () { const path = yield* Path.Path; const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); const codexHomePath = yield* makeTempDir("t3code-codex-home-"); const workspace = yield* makeTempDir("t3code-workspace-"); const otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); const rollout = (year: string, month: string, day: string, name: string) => path.join(codexHomePath, "sessions", year, month, day, name); yield* writeTranscript({ filePath: rollout("2026", "01", "05", "rollout-2026-01-05T10-00-00-aaa.jsonl"), contents: codexRolloutLine(workspace), mtimeMs: Date.parse("2026-01-05T10:00:00.000Z"), }); yield* writeTranscript({ filePath: rollout("2026", "02", "09", "rollout-2026-02-09T10-00-00-bbb.jsonl"), contents: codexRolloutLine(workspace), mtimeMs: Date.parse("2026-02-09T10:00:00.000Z"), }); yield* writeTranscript({ filePath: rollout("2026", "02", "09", "rollout-2026-02-09T11-00-00-ccc.jsonl"), contents: codexRolloutLine(otherWorkspace), mtimeMs: Date.parse("2026-02-09T11:00:00.000Z"), }); const result = yield* runScan({ claudeHomePath, codexHomePath }); expect(result.candidates).toEqual([ { path: otherWorkspace, title: path.basename(otherWorkspace), sources: ["codex"], threadCount: 1, lastActiveAt: "2026-02-09T11:00:00.000Z", alreadyImported: false, }, { path: workspace, title: path.basename(workspace), sources: ["codex"], threadCount: 2, lastActiveAt: "2026-02-09T10:00:00.000Z", alreadyImported: false, }, ]); }), ); it.effect("merges the same cwd seen by both agents and flags imported projects", () => Effect.gen(function* () { const path = yield* Path.Path; const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); const codexHomePath = yield* makeTempDir("t3code-codex-home-"); const workspace = yield* makeTempDir("t3code-workspace-"); yield* writeTranscript({ filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), contents: claudeSessionLine(workspace), mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), }); yield* writeTranscript({ filePath: path.join( codexHomePath, "sessions", "2026", "04", "01", "rollout-2026-04-01T09-00-00-aaa.jsonl", ), contents: codexRolloutLine(workspace), mtimeMs: Date.parse("2026-04-01T09:00:00.000Z"), }); const result = yield* runScan({ claudeHomePath, codexHomePath, importedWorkspaceRoots: [workspace], }); expect(result.candidates).toEqual([ { path: workspace, title: path.basename(workspace), sources: ["claudeAgent", "codex"], threadCount: 2, lastActiveAt: "2026-04-01T09:00:00.000Z", alreadyImported: true, }, ]); }), ); it.effect("drops candidates whose directory no longer exists", () => Effect.gen(function* () { const path = yield* Path.Path; const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); const codexHomePath = yield* makeTempDir("t3code-codex-home-"); yield* writeTranscript({ filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), contents: claudeSessionLine(path.join(claudeHomePath, "does-not-exist")), mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), }); const result = yield* runScan({ claudeHomePath, codexHomePath }); expect(result.candidates).toEqual([]); }), ); it.effect("excludes T3-managed worktree sandboxes", () => Effect.gen(function* () { const path = yield* Path.Path; const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); const codexHomePath = yield* makeTempDir("t3code-codex-home-"); const fileSystem = yield* FileSystem.FileSystem; const worktreeCwd = path.join(claudeHomePath, ".t3", "worktrees", "t3code", "wt-1"); yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); yield* writeTranscript({ filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), contents: claudeSessionLine(worktreeCwd), mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), }); const result = yield* runScan({ claudeHomePath, codexHomePath }); expect(result.candidates).toEqual([]); }), ); it.effect("excludes sandboxes under the configured worktrees dir without .t3 in the path", () => Effect.gen(function* () { const path = yield* Path.Path; const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); const codexHomePath = yield* makeTempDir("t3code-codex-home-"); const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); const fileSystem = yield* FileSystem.FileSystem; // worktreesDir derives as `/worktrees`, and the temp base // dir contains no `.t3` segment — only the config-based prefix match // can exclude this one. const worktreeCwd = path.join(configBaseDir, "worktrees", "t3code", "wt-2"); yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); yield* writeTranscript({ filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), contents: claudeSessionLine(worktreeCwd), mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), }); const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir }); expect(result.candidates).toEqual([]); }), ); it.effect("excludes sandboxes reached through a symlink into the worktrees dir", () => Effect.gen(function* () { const path = yield* Path.Path; const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); const codexHomePath = yield* makeTempDir("t3code-codex-home-"); const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); const linkParent = yield* makeTempDir("t3code-scanner-links-"); const fileSystem = yield* FileSystem.FileSystem; // The recorded cwd is a symlink whose own spelling looks harmless; // only its realpath reveals the managed sandbox. const worktreeCwd = path.join(configBaseDir, "worktrees", "t3code", "wt-3"); yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); const symlinkCwd = path.join(linkParent, "innocent-project"); yield* fileSystem.symlink(worktreeCwd, symlinkCwd); yield* writeTranscript({ filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), contents: claudeSessionLine(symlinkCwd), mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), }); const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir }); expect(result.candidates).toEqual([]); }), ); it.effect("skips malformed transcripts without failing the scan", () => Effect.gen(function* () { const path = yield* Path.Path; const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); const codexHomePath = yield* makeTempDir("t3code-codex-home-"); const workspace = yield* makeTempDir("t3code-workspace-"); yield* writeTranscript({ filePath: path.join(claudeHomePath, "projects", "-broken", "a.jsonl"), contents: "not json at all\n", mtimeMs: Date.parse("2026-05-01T00:00:00.000Z"), }); // Valid JSON, but no cwd anywhere in the record. yield* writeTranscript({ filePath: path.join(claudeHomePath, "projects", "-no-cwd", "a.jsonl"), contents: `{"type":"summary"}\n`, mtimeMs: Date.parse("2026-05-02T00:00:00.000Z"), }); yield* writeTranscript({ filePath: path.join(claudeHomePath, "projects", "-good", "a.jsonl"), contents: claudeSessionLine(workspace), mtimeMs: Date.parse("2026-05-03T00:00:00.000Z"), }); const result = yield* runScan({ claudeHomePath, codexHomePath }); expect(result.candidates).toEqual([ { path: workspace, title: path.basename(workspace), sources: ["claudeAgent"], threadCount: 1, lastActiveAt: "2026-05-03T00:00:00.000Z", alreadyImported: false, }, ]); }), ); it.effect("returns an empty result when neither home directory exists", () => Effect.gen(function* () { const path = yield* Path.Path; const root = yield* makeTempDir("t3code-missing-homes-"); const result = yield* runScan({ claudeHomePath: path.join(root, "no-claude"), codexHomePath: path.join(root, "no-codex"), }); expect(result.candidates).toEqual([]); expect(result.scannedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); }), ); }); });