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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions packages/cli/src/commands/import.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,4 +461,57 @@ describe("basou import codex", () => {
),
).rejects.toThrow("Codex sessions directory not found");
});

it("errors when --session matches no rollout in the project", async () => {
const repo = await setupInitedRepo();
await writeRollout("codex-1", codexActionRollout(repo, "codex-1"));
await expect(
doRunImportCodex({ session: "nope" }, { cwd: repo, codexSessionsDir: getCodexRoot() }),
).rejects.toThrow("Codex rollout not found for session id in project");
});

it("dedup is scoped by source kind: never touches a Claude session sharing an id", async () => {
const repo = await setupInitedRepo();
// A Claude-derived session whose external_id collides with a Codex id.
const claudeSid = "ses_01HXABCDEF1234567890ABCDEF";
const dir = join(basouPaths(repo).sessions, claudeSid);
await mkdir(dir, { recursive: true });
await writeYamlFile(join(dir, "session.yaml"), {
schema_version: "0.1.0",
session: {
id: claudeSid,
workspace_id: FIXED_WS_ID,
source: { kind: "claude-code-import", version: "0.1.0", external_id: "shared-id" },
started_at: "2026-05-10T00:00:00.000Z",
status: "imported",
working_directory: ".",
invocation: { command: "claude", args: [], exit_code: null },
related_files: [],
events_log: "events.jsonl",
label: "claude-code 2026-05-10: 1 command",
},
});
await writeRollout("shared-id", codexActionRollout(repo, "shared-id"));

// --force would delete a same-id session; the Claude one must survive
// because dedup is scoped to source.kind.
await doRunImportCodex(
{ all: true, force: true },
{ cwd: repo, codexSessionsDir: getCodexRoot() },
);

const dirs = await listSessionDirs(repo);
expect(dirs).toContain(claudeSid);
expect(dirs).toHaveLength(2);
const kinds = await Promise.all(
dirs.map(
async (d) =>
SessionSchema.parse(
await readYamlFile(join(basouPaths(repo).sessions, d, "session.yaml")),
).session.source.kind,
),
);
expect(kinds.filter((k) => k === "claude-code-import")).toHaveLength(1);
expect(kinds.filter((k) => k === "codex-import")).toHaveLength(1);
});
});
41 changes: 30 additions & 11 deletions packages/cli/src/commands/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
assertBasouRootSafe,
type BasouPaths,
basouPaths,
CLAUDE_IMPORT_SOURCE,
type ClaudeTranscriptRecord,
CODEX_IMPORT_SOURCE,
type CodexRolloutRecord,
claudeTranscriptToImportPayload,
codexRolloutToImportPayload,
Expand All @@ -22,6 +24,7 @@ import {
type Session,
type SessionImportPayload,
SessionImportPayloadSchema,
type SessionSourceKind,
} from "@basou/core";
import type { Command } from "commander";
import { isVerbose, renderCliError } from "../lib/error-render.js";
Expand Down Expand Up @@ -178,7 +181,7 @@ export async function doRunImportClaudeCode(
};
});

await importDerivedSessions(paths, manifest, options, candidates);
await importDerivedSessions(paths, manifest, options, CLAUDE_IMPORT_SOURCE, candidates);
}

export async function doRunImportCodex(
Expand All @@ -201,7 +204,7 @@ export async function doRunImportCodex(
}),
}));

await importDerivedSessions(paths, manifest, options, candidates);
await importDerivedSessions(paths, manifest, options, CODEX_IMPORT_SOURCE, candidates);
}

function assertSelector(options: ImportOptions): void {
Expand All @@ -225,15 +228,18 @@ async function resolveImportTarget(
* The vendor-neutral import core: dedup, derive, validate, and write each
* candidate, then report. Every `basou import <adapter>` funnels its
* discovered candidates through here, so dedup / `--force` / `--dry-run`
* semantics stay identical across adapters.
* semantics stay identical across adapters. Dedup is scoped to `sourceKind`
* so one adapter never matches (or, under `--force`, deletes) another
* adapter's session that happens to share an id string.
*/
async function importDerivedSessions(
paths: BasouPaths,
manifest: Manifest,
options: ImportOptions,
sourceKind: SessionSourceKind,
candidates: ReadonlyArray<ImportCandidate>,
): Promise<void> {
const existingByExternalId = await loadExistingByExternalId(paths);
const existingByExternalId = await loadExistingByExternalId(paths, sourceKind);
// Session ids imported earlier in THIS run, so two source files that map to
// one session id never double-import within a single invocation.
const seenThisRun = new Set<string>();
Expand Down Expand Up @@ -312,13 +318,19 @@ function encodeProjectDir(projectPath: string): string {

/**
* Map of source external_id -> Basou session id(s) already present in the
* workspace, so a re-import can skip (default) or, under --force, delete and
* replace the existing session. Recognises both the structured
* `source.external_id` (current imports) and the `claude-code import <id>`
* label form (sessions imported before external_id existed), so existing
* dogfood imports are matched either way. Unreadable sessions are skipped.
* workspace for the given `sourceKind`, so a re-import can skip (default) or,
* under --force, delete and replace the existing session. Scoping to one
* source kind keeps each adapter's id namespace separate: a Codex import must
* never dedup against, or delete, a Claude-derived session that happens to
* share an id string. Recognises both the structured `source.external_id`
* (current imports) and the `claude-code import <id>` label form (sessions
* imported before external_id existed), so existing dogfood imports are
* matched either way. Unreadable sessions are skipped.
*/
async function loadExistingByExternalId(paths: BasouPaths): Promise<Map<string, string[]>> {
async function loadExistingByExternalId(
paths: BasouPaths,
sourceKind: SessionSourceKind,
): Promise<Map<string, string[]>> {
const byExternalId = new Map<string, string[]>();
const add = (externalId: string, sessionId: string): void => {
const list = byExternalId.get(externalId);
Expand All @@ -338,6 +350,7 @@ async function loadExistingByExternalId(paths: BasouPaths): Promise<Map<string,
} catch {
continue;
}
if (session.session.source.kind !== sourceKind) continue;
const ext = session.session.source.external_id;
if (typeof ext === "string" && ext.length > 0) {
add(ext, sessionId);
Expand Down Expand Up @@ -378,7 +391,10 @@ async function selectTranscriptFiles(
* tree is walked and each rollout's `session_meta.cwd` is matched against the
* project. The exact-match is also the safety boundary: only sessions started
* in the requested project are ever imported. `--session` narrows to a single
* rollout by its Codex session id within that project.
* rollout by its Codex session id within that project; a session id that
* matches no rollout in the project is an error, mirroring how the Claude
* path fails on a missing `--session` transcript rather than reporting a
* silent success.
*/
async function discoverCodexRollouts(
sessionsRoot: string,
Expand All @@ -394,6 +410,9 @@ async function discoverCodexRollouts(
if (options.session !== undefined && meta.id !== options.session) continue;
matched.push({ file, externalId: meta.id });
}
if (options.session !== undefined && matched.length === 0) {
throw new Error("Codex rollout not found for session id in project");
}
return matched;
}

Expand Down