diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md
index 8655c955..13517bd0 100644
--- a/apps/web/AGENTS.md
+++ b/apps/web/AGENTS.md
@@ -189,6 +189,11 @@ When to add or change one:
- Every route is under `/api/v1/` (plus `/auth/*`, `/healthz`). It must pass the auth cookie and Origin checks in
`server.ts`; the server listens on `127.0.0.1` only. Never log the launch token or the cookie secret.
+- `/api/v1/session-imports` adapts the coding-agent offline migration service.
+ Scan the Host machine, exclude archives by default, and require previewed
+ fingerprints plus explicit destination directories before importing. Close
+ the importer before the session index. Imported tools never create native
+ child-agent links, revertable file changes or running-state indicators.
- Adding or changing a route or payload: update `openapi.yaml` in the same change, update `client/src/state/types.ts`,
and add a unit test.
- Session snapshots and every SSE event carry `revision`, monotonic per bound session. `renderSnapshot` ignores a
@@ -209,6 +214,9 @@ When to add or change one:
separate. Preserve original
timeline indices for turn footers, file deliveries and search reveal. Group
completion counts tool results, never canonical Tasks or child-agent success.
+ Imported ordinary calls with unknown outcomes may join folded groups; a group
+ containing unconfirmed results uses a neutral recorded-call summary, never a
+ completion claim. Historical tool calls cannot make a group running.
- A subagent record is context the main agent produced, so its inspector has no prompt surface:
the child's input is a collapsed "task message received" receipt, never a bubble or box, and the parent
composer is hidden while the record is open (closing it restores the composer). Do not add a composer,
diff --git a/apps/web/client/public/session-import-logos/claudecode.svg b/apps/web/client/public/session-import-logos/claudecode.svg
new file mode 100644
index 00000000..281f8860
--- /dev/null
+++ b/apps/web/client/public/session-import-logos/claudecode.svg
@@ -0,0 +1,10 @@
+
+ Claude Code
+
+
+
+
+
+
+
+
diff --git a/apps/web/client/public/session-import-logos/codex.svg b/apps/web/client/public/session-import-logos/codex.svg
new file mode 100644
index 00000000..d72e272b
--- /dev/null
+++ b/apps/web/client/public/session-import-logos/codex.svg
@@ -0,0 +1,4 @@
+
+ Codex
+
+
diff --git a/apps/web/client/public/session-import-logos/qoder-dark.svg b/apps/web/client/public/session-import-logos/qoder-dark.svg
new file mode 100644
index 00000000..aac56b17
--- /dev/null
+++ b/apps/web/client/public/session-import-logos/qoder-dark.svg
@@ -0,0 +1,4 @@
+
+ Qoder
+
+
diff --git a/apps/web/client/public/session-import-logos/workbuddy.svg b/apps/web/client/public/session-import-logos/workbuddy.svg
new file mode 100644
index 00000000..cd52c984
--- /dev/null
+++ b/apps/web/client/public/session-import-logos/workbuddy.svg
@@ -0,0 +1,4 @@
+
+ WorkBuddy
+
+
diff --git a/apps/web/client/src/features/conversation/ConversationStream.tsx b/apps/web/client/src/features/conversation/ConversationStream.tsx
index 6797dcf9..8392f0de 100644
--- a/apps/web/client/src/features/conversation/ConversationStream.tsx
+++ b/apps/web/client/src/features/conversation/ConversationStream.tsx
@@ -6,6 +6,7 @@ import { setUi, useAppStore } from "../../state/store";
import type { MessageTimelineItem, SteeringMessage, TimelineItem } from "../../state/types";
import { Icon } from "../../ui";
import { Composer } from "../composer";
+import { openSettings } from "../settings/actions";
import { ClarificationPanel } from "./ClarificationPanel";
import { CommandRow } from "./CommandRow";
import { CompactionRow, CompactionRunningRow } from "./CompactionRow";
@@ -35,12 +36,26 @@ import { collectTurnFooters } from "./turn-footers";
*/
function EmptyConversation() {
const t = useT();
+ const draft = useAppStore((state) => state.current === null);
return (
-
-
-
-
-
{t("emptyTitle")}
+
+
+
+
+
+
{t("emptyTitle")}
+
+ {draft ? (
+
openSettings("migration")}
+ >
+
+ {t("emptySessionImport")}
+
+ ) : null}
);
}
diff --git a/apps/web/client/src/features/conversation/ToolCall.tsx b/apps/web/client/src/features/conversation/ToolCall.tsx
index 6ff36dea..9a0be293 100644
--- a/apps/web/client/src/features/conversation/ToolCall.tsx
+++ b/apps/web/client/src/features/conversation/ToolCall.tsx
@@ -82,9 +82,9 @@ export const ToolRow = memo(function ToolRow({
const call = callPath(item.args, cwd);
const shown = call.shown;
// Only a file opens in the preview; a search or list path is usually a folder.
- const openable = FILE_KINDS.has(toolKind(item)) ? call.openable : null;
+ const openable = !item.imported && FILE_KINDS.has(toolKind(item)) ? call.openable : null;
const copyValue = toolKind(item) === "bash" ? (item.args?.command ?? "") : output;
- const card = toolKind(item) === "edit" || toolKind(item) === "write";
+ const card = !item.imported && (toolKind(item) === "edit" || toolKind(item) === "write");
if (!readOnly && item.toolName === "subagent" && item.subagentTasks?.length && item.state !== "failed") {
return
;
}
diff --git a/apps/web/client/src/features/conversation/process-groups.ts b/apps/web/client/src/features/conversation/process-groups.ts
index cb37c436..392447c8 100644
--- a/apps/web/client/src/features/conversation/process-groups.ts
+++ b/apps/web/client/src/features/conversation/process-groups.ts
@@ -52,7 +52,9 @@ export function groupProcessRows(
rows: pending,
running: pending.some(
({ item, index }) =>
- active && index > lastUser && (item.kind === "thinking" ? item.running : item.state === "pending"),
+ active &&
+ index > lastUser &&
+ (item.kind === "thinking" ? item.running : !item.imported && item.state === "pending"),
),
});
pending = [];
@@ -69,7 +71,9 @@ export function groupProcessRows(
!item.children?.length &&
!item.workflowRun &&
item.state !== "failed" &&
- (item.state === "completed" || (active && index > lastUser && !awaiting.has(item.toolName))))
+ (item.state === "completed" ||
+ (item.imported && item.state === "unknown") ||
+ (active && index > lastUser && !awaiting.has(item.toolName))))
) {
pending.push({ ...row, item });
} else {
@@ -137,6 +141,9 @@ export function processGroupSummary(
};
}
if (!tools.length) return { label: t("processThinkingRecorded"), detail: "", icon: "brain" };
+ if (tools.some((tool) => tool.state !== "completed")) {
+ return { label: t("processRecordedTools", { count: tools.length }), detail: "", icon: "wrench" };
+ }
const counts = new Map
();
for (const tool of tools) {
const kind = toolKind(tool);
diff --git a/apps/web/client/src/features/conversation/stream.css b/apps/web/client/src/features/conversation/stream.css
index 52cc0b89..cec84c8e 100644
--- a/apps/web/client/src/features/conversation/stream.css
+++ b/apps/web/client/src/features/conversation/stream.css
@@ -140,14 +140,22 @@
.main:has(.empty-state):not(:has(.stream[data-opening])) > .composer-zone {
flex: 1 1 auto;
}
-/* The mark leads the question on one line, centred on the column, just above the composer. */
+.empty-conversation {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-end;
+ flex: 1;
+ padding: 0 2px 16px;
+}
+/* The mark and question stay centred on one line, independently of the migration shortcut. */
.empty-state {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 14px;
- flex: 1;
- padding: 0 2px 16px;
+ flex: 0 0 auto;
+ width: 100%;
color: var(--ink-2);
text-align: center;
}
@@ -197,6 +205,21 @@
}
}
+.empty-session-import {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 20px;
+ padding: 8px 12px;
+ border-radius: var(--radius-control);
+ color: var(--ink-2);
+ font-size: 13px;
+ text-align: left;
+ transition: background-color var(--fast), color var(--fast);
+}
+.empty-session-import:hover { background: var(--hover); color: var(--ink); }
+.empty-session-import:focus-visible { outline: 2px solid var(--focus-border); outline-offset: 2px; }
+
.message {
display: flex;
flex-direction: column;
diff --git a/apps/web/client/src/features/conversation/tool-presentation.ts b/apps/web/client/src/features/conversation/tool-presentation.ts
index df852f7d..9cf3efd0 100644
--- a/apps/web/client/src/features/conversation/tool-presentation.ts
+++ b/apps/web/client/src/features/conversation/tool-presentation.ts
@@ -14,12 +14,44 @@ const KIND_BY_TOOL: Readonly> = {
ls: "list",
};
-export function toolKind(item: Pick): ToolKind {
+/** Presentation-only grouping of historical tool names; execution names are never rewritten. */
+const IMPORTED_KIND_BY_TOOL: Readonly> = {
+ exec_command: "bash",
+ shell: "bash",
+ bash: "bash",
+ run_terminal_cmd: "bash",
+ read: "read",
+ read_file: "read",
+ cat: "read",
+ write: "write",
+ write_file: "write",
+ apply_patch: "write",
+ create_file: "write",
+ edit: "edit",
+ edit_file: "edit",
+ str_replace_editor: "edit",
+ search_replace: "edit",
+ grep: "search",
+ glob: "search",
+ search_files: "search",
+ codebase_search: "search",
+ web_search: "search",
+ ls: "list",
+ list_dir: "list",
+ list_directory: "list",
+};
+
+export function toolKind(item: Pick): ToolKind {
+ if (item.imported) {
+ const name = item.toolName.toLowerCase();
+ return Object.hasOwn(IMPORTED_KIND_BY_TOOL, name) ? IMPORTED_KIND_BY_TOOL[name] : "other";
+ }
return Object.hasOwn(KIND_BY_TOOL, item.toolName) ? KIND_BY_TOOL[item.toolName] : "other";
}
/** State comes from the host, never from output text or the whole turn's activity. */
-export function toolLabel(item: Pick, t: TranslateFn): string {
+export function toolLabel(item: Pick, t: TranslateFn): string {
+ if (item.state === "unknown") return item.toolName || t("toolStatus.unknown");
const kind = toolKind(item);
const action = item.toolName === "find" ? "find" : kind;
return t(`toolAction.${action}.${item.state}`, { name: item.toolName || t("approvalTool") });
diff --git a/apps/web/client/src/features/conversation/turn-artifacts.ts b/apps/web/client/src/features/conversation/turn-artifacts.ts
index a3fb99a2..9b960ad6 100644
--- a/apps/web/client/src/features/conversation/turn-artifacts.ts
+++ b/apps/web/client/src/features/conversation/turn-artifacts.ts
@@ -37,7 +37,7 @@ export function turnArtifacts(
const keyOf = (path: string) => (windows ? path.toLowerCase() : path);
for (let index = Math.max(0, start); index <= footer.lastIndex; index++) {
const item = items[index];
- if (item.kind !== "tool" || item.state !== "completed") continue;
+ if (item.kind !== "tool" || item.state !== "completed" || item.imported) continue;
if (!["edit", "write", "edit_file", "write_file", "apply_patch"].includes(item.toolName)) continue;
const raw = item.change?.path ?? item.args?.path;
const path = raw ? workspaceFilePath(cwd, raw) : undefined;
diff --git a/apps/web/client/src/features/settings/SessionImportPage.tsx b/apps/web/client/src/features/settings/SessionImportPage.tsx
new file mode 100644
index 00000000..a9ff408c
--- /dev/null
+++ b/apps/web/client/src/features/settings/SessionImportPage.tsx
@@ -0,0 +1,500 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useT } from "../../i18n";
+import { api, apiSend } from "../../lib/api";
+import { openSession, refreshSessions } from "../../state/session";
+import { useAppStore } from "../../state/store";
+import type { ImportCandidate, ImportJob, ImportPreview } from "../../state/types";
+import { Button, Icon, Popover, SearchInput } from "../../ui";
+import { closeSettings } from "./actions";
+import { displayableImportCandidates, pendingImportCandidates } from "./session-import-history";
+import "./session-import.css";
+
+const PATH = "/api/v1/session-imports";
+const SOURCES = [
+ { id: "codex", name: "Codex", logo: "/session-import-logos/codex.svg" },
+ { id: "claude-code", name: "Claude Code", logo: "/session-import-logos/claudecode.svg" },
+ { id: "workbuddy", name: "WorkBuddy", logo: "/session-import-logos/workbuddy.svg" },
+ { id: "qoder", name: "Qoder", logo: "/session-import-logos/qoder-dark.svg" },
+] as const;
+
+function groupProjects(rows: readonly T[], cwd: (row: T) => string) {
+ const groups = new Map();
+ for (const row of rows) {
+ const path = cwd(row);
+ const items = groups.get(path) ?? [];
+ items.push(row);
+ groups.set(path, items);
+ }
+ return [...groups];
+}
+
+function projectName(path: string, fallback: string) {
+ return path.split(/[\\/]/u).filter(Boolean).at(-1) || fallback;
+}
+
+function remainingSelected(selected: ReadonlySet, job: ImportJob) {
+ const completed = new Set(
+ job.results.filter((result) => result.status !== "failed").map((result) => result.candidateId),
+ );
+ return new Set([...selected].filter((id) => !completed.has(id)));
+}
+
+export function SessionImportPage() {
+ const t = useT();
+ const sessions = useAppStore((state) => state.sessions);
+ const [candidates, setCandidates] = useState([]);
+ const [selected, setSelected] = useState>(new Set());
+ const [previews, setPreviews] = useState>(new Map());
+ const [targets, setTargets] = useState>(new Map());
+ const [query, setQuery] = useState("");
+ const [scanned, setScanned] = useState(false);
+ const [scanGeneration, setScanGeneration] = useState(0);
+ const [busy, setBusy] = useState(false);
+ const [focused, setFocused] = useState();
+ const [job, setJob] = useState();
+ const [jobs, setJobs] = useState([]);
+ const mounted = useRef(true);
+ const autoScanned = useRef(false);
+
+ useEffect(() => {
+ mounted.current = true;
+ return () => {
+ mounted.current = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (job?.status !== "running") return;
+ let stopped = false;
+ let timer: ReturnType | undefined;
+ const poll = async () => {
+ try {
+ const next = await api(`${PATH}/${job.id}`);
+ if (stopped) return;
+ setJob(next);
+ setJobs((current) => [next, ...current.filter((item) => item.id !== next.id)]);
+ if (next.status === "running") timer = setTimeout(() => void poll(), 500);
+ else {
+ setSelected((current) => remainingSelected(current, next));
+ await refreshSessions();
+ }
+ } catch {
+ // Read-only retries preserve the last known job state without displaying technical diagnostics.
+ if (!stopped) timer = setTimeout(() => void poll(), 2000);
+ }
+ };
+ void poll();
+ return () => {
+ stopped = true;
+ if (timer) clearTimeout(timer);
+ };
+ }, [job?.id, job?.status]);
+
+ const pending = busy || job?.status === "running";
+ const available = useMemo(
+ () => displayableImportCandidates(candidates, jobs, previews),
+ [candidates, jobs, previews],
+ );
+ const visible = useMemo(
+ () => displayableImportCandidates(candidates, jobs, previews, query),
+ [candidates, jobs, previews, query],
+ );
+ const chosen = available.filter((c) => selected.has(c.id));
+
+ function groupCheckbox(rows: readonly ImportCandidate[], scope: string) {
+ const all = rows.length > 0 && rows.every((row) => selected.has(row.id));
+ const some = rows.some((row) => selected.has(row.id));
+ const label = scope.includes(":")
+ ? projectName(rows[0]?.cwd ?? "", t("importUnknownProject"))
+ : (SOURCES.find((source) => source.id === scope)?.name ?? t("importUnknownSource"));
+ return (
+ {
+ if (input) input.indeterminate = some && !all;
+ }}
+ onClick={(event) => event.stopPropagation()}
+ onChange={(event) => {
+ const checked = event.currentTarget.checked;
+ setSelected((current) => {
+ const next = new Set(current);
+ for (const row of rows) {
+ if (checked) next.add(row.id);
+ else next.delete(row.id);
+ }
+ return next;
+ });
+ }}
+ />
+ );
+ }
+
+ const preview = useCallback(async (ids: readonly string[], show = true) => {
+ setBusy(true);
+ if (show && ids[0]) setFocused(ids[0]);
+ try {
+ for (const id of ids) {
+ if (!mounted.current) return;
+ try {
+ const value = await apiSend(`${PATH}/preview`, "POST", { candidateId: id });
+ if (!mounted.current) return;
+ setPreviews((current) => new Map(current).set(id, value));
+ } catch {
+ // An unavailable preview must not leave a stale selectable row or open disclosure.
+ if (!mounted.current) return;
+ setPreviews((current) => {
+ const next = new Map(current);
+ next.delete(id);
+ return next;
+ });
+ setSelected((current) => {
+ const next = new Set(current);
+ next.delete(id);
+ return next;
+ });
+ setFocused((current) => (current === id ? undefined : current));
+ }
+ }
+ } finally {
+ if (mounted.current) setBusy(false);
+ }
+ }, []);
+
+ const scan = useCallback(async () => {
+ setBusy(true);
+ setScanned(false);
+ setPreviews(new Map());
+ setSelected(new Set());
+ setFocused(undefined);
+ try {
+ const history = await api<{ jobs: ImportJob[] }>(PATH);
+ if (!mounted.current) return;
+ setJobs(history.jobs);
+ setJob(
+ (current) =>
+ history.jobs.find((item) => item.status === "running") ??
+ (current && history.jobs.find((item) => item.id === current.id)),
+ );
+ const result = await apiSend<{ candidates: ImportCandidate[] }>(`${PATH}/scan`, "POST", {
+ includeArchived: false,
+ });
+ if (!mounted.current) return;
+ setCandidates(result.candidates);
+ setSelected(new Set());
+ setPreviews(new Map());
+ setTargets(new Map(result.candidates.map((candidate) => [candidate.id, candidate.cwd])));
+ setFocused(undefined);
+ setScanned(true);
+ setScanGeneration((current) => current + 1);
+ await preview(
+ pendingImportCandidates(result.candidates, history.jobs).map((candidate) => candidate.id),
+ false,
+ );
+ } catch {
+ // Preserve known history and metadata; failed optional discovery renders no source list.
+ if (mounted.current) {
+ setScanned(false);
+ setPreviews(new Map());
+ }
+ } finally {
+ if (mounted.current) setBusy(false);
+ }
+ }, [preview]);
+ useEffect(() => {
+ if (autoScanned.current) return;
+ autoScanned.current = true;
+ void scan();
+ }, [scan]);
+
+ async function start(source: ImportCandidate["source"]) {
+ const platformChosen = chosen.filter((candidate) => candidate.source === source);
+ if (
+ !platformChosen.length ||
+ platformChosen.length > 100 ||
+ !platformChosen.every((c) => previews.has(c.id) && targets.get(c.id)?.trim())
+ )
+ return;
+ setBusy(true);
+ try {
+ const next = await apiSend(PATH, "POST", {
+ selections: platformChosen.map((c) => ({
+ candidateId: c.id,
+ fingerprint: previews.get(c.id)?.fingerprint,
+ cwd: targets.get(c.id),
+ })),
+ });
+ if (mounted.current) {
+ setJob(next);
+ if (next.status !== "running") setSelected((current) => remainingSelected(current, next));
+ setJobs((current) => [next, ...current.filter((item) => item.id !== next.id)]);
+ }
+ } catch {
+ // Submission is unconfirmed; keep selection and history, never auto-retry or invent success.
+ } finally {
+ if (mounted.current) setBusy(false);
+ }
+ }
+
+ async function cancel() {
+ if (!job) return;
+ try {
+ await apiSend(`${PATH}/${job.id}`, "DELETE");
+ } catch {
+ // Keep the last known running state; only a confirmed host response can report cancellation.
+ }
+ }
+
+ return (
+
+
+
+
+ setQuery(event.currentTarget.value)}
+ />
+
+
+ {SOURCES.map((source) => {
+ const rows = visible.filter((candidate) => candidate.source === source.id);
+ const platformChosen = chosen.filter((candidate) => candidate.source === source.id);
+ const ready =
+ platformChosen.length > 0 &&
+ platformChosen.every((c) => previews.has(c.id) && targets.get(c.id)?.trim());
+ if (!scanned || !rows.length) return null;
+ return (
+
+
+ {groupCheckbox(rows, source.id)}
+ {"logo" in source ? (
+
+ ) : (
+
+ )}
+
+ {source.name}
+
+
+ {t("importSelected", {
+ count: available.filter(
+ (candidate) => candidate.source === source.id && selected.has(candidate.id),
+ ).length,
+ })}
+
+
+ 100}
+ onClick={(event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ void start(source.id);
+ }}
+ >
+ {t("importConfirm")}
+
+
+
+
+ {groupProjects(rows, (candidate) => candidate.cwd).map(([cwd, projectRows]) => (
+
+
+ {groupCheckbox(projectRows, `${source.id}:${cwd}`)}
+
+
+ {projectName(cwd, t("importUnknownProject"))}
+
+
+ {t("importSelected", {
+ count: available.filter(
+ (candidate) =>
+ candidate.source === source.id &&
+ candidate.cwd === cwd &&
+ selected.has(candidate.id),
+ ).length,
+ })}
+
+
+
+ {projectRows.map((candidate) => {
+ const p = previews.get(candidate.id);
+ return (
+
+
+ {
+ const next = new Set(selected);
+ if (event.target.checked) next.add(candidate.id);
+ else next.delete(candidate.id);
+ setSelected(next);
+ }}
+ />
+
+ {p?.title || candidate.title}
+ {candidate.archived ? {t("importArchived")} : null}
+
+
+
setFocused(undefined)}
+ content={
+ p ? (
+ <>
+ {p.title}
+ {p.excerpt}
+ >
+ ) : null
+ }
+ >
+ {
+ if (focused === candidate.id) setFocused(undefined);
+ else void preview([candidate.id]);
+ }}
+ >
+ {t("importPreview")}
+
+
+
+ {p ? (
+
+
+ {t("importCounts", {
+ messages: p.messageCount,
+ tools: p.toolCount,
+ })}
+
+
+ ) : null}
+
+ );
+ })}
+
+
+ ))}
+
+
+ );
+ })}
+
+
+
+
+ {t("importHistoryTitle")}
+ {job?.status === "running" ? (
+ void cancel()}>
+ {t("importCancel")}
+
+ ) : null}
+
+ {job ? (
+
+ {t(`importJob.${job.status}`)} · {job.results.length}/{job.total}
+
+ ) : null}
+ {[...SOURCES, { id: "unknown", name: t("importUnknownSource") }].map((source) => {
+ const rows = [...jobs]
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt))
+ .flatMap((item) => item.results.map((result, index) => ({ result, index, job: item })))
+ .filter(({ result }) => (result.source ?? "unknown") === source.id);
+ if (!rows.length) return null;
+ return (
+
+
+ {"logo" in source ? (
+
+ ) : (
+
+ )}
+
+ {source.name}
+
+
+
+
+ {groupProjects(
+ rows,
+ ({ result }) =>
+ sessions.find((session) => session.id === result.sessionId)?.cwd ||
+ candidates.find((candidate) => candidate.id === result.candidateId)?.cwd ||
+ "",
+ ).map(([cwd, projectRows]) => (
+
+
+
+
+ {projectName(cwd, t("importUnknownProject"))}
+
+
+ {projectRows.map(({ result, index, job: item }) => (
+
+
+
{result.title ?? t("importUnknownProject")}
+
{t(`importResult.${result.status}`)}
+
+ {result.sessionId ? (
+
{
+ closeSettings();
+ if (result.sessionId)
+ void openSession(result.sessionId, { reportErrors: false });
+ }}
+ >
+ {t("importOpen")}
+
+ ) : null}
+
+ ))}
+
+
+ ))}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/apps/web/client/src/features/settings/SettingsView.tsx b/apps/web/client/src/features/settings/SettingsView.tsx
index 497e92fd..e0785d98 100644
--- a/apps/web/client/src/features/settings/SettingsView.tsx
+++ b/apps/web/client/src/features/settings/SettingsView.tsx
@@ -16,6 +16,7 @@ import { GeneralPage } from "./GeneralPage";
import { McpPage } from "./McpPage";
import { ModelsPage } from "./ModelsPage";
import { PluginsPage } from "./PluginsPage";
+import { SessionImportPage } from "./SessionImportPage";
import { SkillsPage } from "./SkillsPage";
interface MenuEntry {
@@ -32,6 +33,7 @@ const MENU: ReadonlyArray<{ readonly title: string; readonly entries: readonly M
{ page: "general", label: "settingsGeneral", icon: "sliders" },
{ page: "models", label: "settingsModels", icon: "cube" },
{ page: "appearance", label: "settingsAppearance", icon: "palette" },
+ { page: "migration", label: "importTitle", icon: "folder" },
],
},
{
@@ -60,6 +62,8 @@ function pageContent(page: SettingsPage): ReactNode {
return ;
case "skills":
return ;
+ case "migration":
+ return ;
}
}
diff --git a/apps/web/client/src/features/settings/session-import-history.ts b/apps/web/client/src/features/settings/session-import-history.ts
new file mode 100644
index 00000000..a02c2807
--- /dev/null
+++ b/apps/web/client/src/features/settings/session-import-history.ts
@@ -0,0 +1,41 @@
+import type { ImportCandidate, ImportJob, ImportPreview } from "../../state/types";
+
+/** Scan IDs change; persisted source paths keep migrated sessions in history across scans. */
+export function pendingImportCandidates(
+ candidates: readonly ImportCandidate[],
+ jobs: readonly ImportJob[],
+): ImportCandidate[] {
+ const completedIds = new Set();
+ const completedPaths = new Set();
+ for (const job of jobs) {
+ for (const result of job.results) {
+ if (result.status !== "imported" && result.status !== "skipped") continue;
+ if (result.candidateId) completedIds.add(result.candidateId);
+ if (result.source && result.sourcePath) completedPaths.add(`${result.source}\0${result.sourcePath}`);
+ }
+ }
+ return candidates.filter(
+ (candidate) =>
+ !completedIds.has(candidate.id) &&
+ !(candidate.sourcePath && completedPaths.has(`${candidate.source}\0${candidate.sourcePath}`)),
+ );
+}
+
+/** Discovery is best-effort: unreviewed, unavailable and unmatched candidates have no row. */
+export function displayableImportCandidates(
+ candidates: readonly ImportCandidate[],
+ jobs: readonly ImportJob[],
+ previews: ReadonlyMap,
+ query = "",
+): ImportCandidate[] {
+ const needle = query.trim().toLowerCase();
+ return pendingImportCandidates(candidates, jobs).filter((candidate) => {
+ const reviewed = previews.get(candidate.id);
+ return (
+ !!reviewed &&
+ `${reviewed.title || candidate.title} ${candidate.cwd} ${candidate.source} ${candidate.sourcePath ?? ""}`
+ .toLowerCase()
+ .includes(needle)
+ );
+ });
+}
diff --git a/apps/web/client/src/features/settings/session-import.css b/apps/web/client/src/features/settings/session-import.css
new file mode 100644
index 00000000..7718340c
--- /dev/null
+++ b/apps/web/client/src/features/settings/session-import.css
@@ -0,0 +1,282 @@
+.session-import-page { display: grid; grid-template-columns: minmax(0, 1fr); gap: 24px; }
+.session-import-root { min-width: 0; flex: 1; overflow-wrap: anywhere; color: var(--ink-3); font-size: 11px; font-weight: 400; }
+.session-import-toolbar > .session-import-check { font-size: 12px; color: var(--ink-2); }
+.session-import-page > section { min-width: 0; }
+.session-import-project-summary { min-width: 0; }
+.session-import-section-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 8px; }
+.session-import-section-head h1, .session-import-section-head h2 { font-size: 18px; font-weight: 600; }
+.session-import-group { border: 1px solid var(--line); border-radius: var(--radius-surface); margin-block: 14px; background: var(--canvas); }
+.session-import-app-summary { display: flex; align-items: center; gap: 14px; padding: 20px; cursor: pointer; list-style: none; font-size: 15px; }
+.session-import-app-summary::-webkit-details-marker { display: none; }
+.session-import-logo-placeholder { flex: 0 0 32px; width: 32px; height: 32px; }
+.session-import-logo { flex: 0 0 32px; display: block; width: 32px; height: 32px; object-fit: contain; border-radius: 8px; }
+.session-import-app-info { flex: 1; min-width: 0; display: grid; gap: 5px; }
+.session-import-app-info strong { font-weight: 600; }
+.session-import-app-info .session-import-root { display: none; }
+.session-import-app-summary > .session-import-app-count { flex: none; color: var(--ink-2); font-size: 12px; }
+.session-import-app-chevron { flex: none; margin-left: 4px; color: var(--ink-2); transform: rotate(90deg); }
+.session-import-group[open] > .session-import-app-summary .session-import-app-chevron { transform: rotate(270deg); }
+.session-import-group[open] > .session-import-app-summary { border-bottom: 1px dashed var(--line); }
+.session-import-app-body { padding: 8px 20px 16px 52px; }
+.session-import-project { margin-top: 4px; }
+.session-import-project + .session-import-project { border-top: 1px dashed var(--line); }
+.session-import-project-summary { display: flex; align-items: center; gap: 8px; padding: 16px 0; cursor: pointer; list-style: none; font-size: 13px; }
+.session-import-project-summary::-webkit-details-marker { display: none; }
+.session-import-project-summary strong { font-weight: 550; overflow-wrap: anywhere; min-width: 0; }
+.session-import-project-summary > span { margin-left: 0; flex: none; font-size: 11px; color: var(--ink-3); }
+.session-import-project-summary > .icon:first-of-type { order: 1; flex: none; margin-left: 8px; transform: rotate(90deg); }
+.session-import-project[open] > summary > .icon:first-of-type { transform: rotate(270deg); }
+.session-import-project-summary:focus-visible { outline: 2px solid var(--focus-border); outline-offset: 2px; }
+.session-import-project-path { color: var(--ink-3); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; flex: 1; font-weight: 400; }
+.session-import-header-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
+.session-import-selected { font-size: 12px; color: var(--ink-2); }
+.session-import-select-group { flex: none; margin: 0; width: 14px; height: 14px; accent-color: var(--ink); cursor: pointer; }
+.session-import-project-summary > span { flex: none; }
+.session-import-project-summary strong { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.session-import-project-body { padding-left: 20px; }
+.session-import-page .settings-page-desc { font-size: 12px; line-height: 1.6; }
+.session-import-page .settings-btn { font-size: 12px; }
+.session-import-app-body > .settings-field { padding: 10px; background: var(--surface-soft); border-radius: var(--radius-control); }
+.session-import-app-body > .session-import-check { font-size: 12px; color: var(--ink-2); }
+.session-import-history { border-top: 1px solid var(--line); padding-top: 20px; }
+.session-import-source-picker { display: flex; gap: 8px; align-items: center; }
+.session-import-source-picker input { flex: 1; min-width: 0; }
+.session-import-source-picker button { flex: none; }
+.session-import-path { font-size: 12px; color: var(--ink-2); overflow-wrap: anywhere; word-break: break-word; }
+.session-import-group .settings-field { margin-bottom: 12px; }
+.session-import-result > div { min-width: 0; }
+.session-import-result p { font-size: 12px; color: var(--ink-2); margin-top: 4px; }
+.session-import-app-summary:focus-visible { outline: 2px solid var(--focus-border); outline-offset: 2px; border-radius: var(--radius-control); }
+
+.session-import-sources {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 16px;
+}
+
+.session-import-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+ margin-block: 16px;
+}
+
+.session-import-toolbar .search-input {
+ flex: 1;
+ min-width: 160px;
+ min-height: 40px;
+ padding: 9px 12px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-control);
+ background: var(--surface-soft);
+ transition: border-color var(--fast), box-shadow var(--fast), background-color var(--fast);
+}
+.session-import-toolbar .search-input:focus-within {
+ border-color: var(--focus-border);
+ background: var(--canvas);
+ box-shadow: 0 0 0 3px var(--accent-ring);
+}
+.session-import-toolbar .search-input input { font-size: 13px; color: var(--ink); }
+.session-import-toolbar .search-input input:focus-visible { outline: none; }
+.session-import-toolbar .search-input input::placeholder { color: var(--ink-3); }
+
+.session-import-check {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+ cursor: pointer;
+}
+
+.session-import-check input {
+ flex: 0 0 auto;
+ accent-color: var(--ink);
+}
+
+.session-import-check span {
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+
+.session-import-check strong {
+ font-size: 13px;
+ font-weight: 500;
+}
+
+.session-import-check small {
+ display: block;
+ font-size: 12px;
+ color: var(--ink-2);
+ margin-top: 4px;
+}
+
+.session-import-group h2,
+.session-import-preview h2,
+.session-import-job h2 {
+ font-size: 13px;
+ font-weight: 600;
+ overflow-wrap: anywhere;
+ margin-block: 16px 8px;
+}
+
+.session-import-row {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ align-items: center;
+ column-gap: 12px;
+ row-gap: 4px;
+ margin-block: 8px;
+ padding: 12px 10px;
+ border-radius: var(--radius-control);
+ transition: background-color var(--fast);
+}
+.session-import-row > .session-import-check { display: contents; }
+.session-import-row > .session-import-check > input { grid-column: 1; grid-row: 1 / 3; align-self: center; margin: 0; }
+.session-import-row > .session-import-check > span { grid-column: 2; grid-row: 1; }
+.session-import-row > .session-import-check strong {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.session-import-row > button { grid-column: 3; grid-row: 1 / 3; align-self: center; }
+.session-import-row > .session-import-report { grid-column: 2; grid-row: 2; padding-left: 0; }
+.session-import-row:has(> .session-import-check input:disabled) .session-import-check { cursor: default; }
+.session-import-row:has(> .session-import-check input:checked) {
+ background: var(--surface-soft);
+}
+
+.session-import-target,
+.session-import-report {
+ grid-column: 1 / -1;
+}
+
+.session-import-target {
+ display: flex;
+ align-items: end;
+ gap: 8px;
+}
+
+.session-import-target label {
+ flex: 1;
+ min-width: 0;
+}
+
+.session-import-excerpt {
+ grid-column: 1 / -1;
+ font-size: 12px;
+ line-height: 1.6;
+ color: var(--ink-2);
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+.session-import-preview-popover.session-import-preview-popover {
+ z-index: 100;
+ position-area: left center;
+ position-try-fallbacks: flip-inline, flip-block;
+ margin: 8px;
+ width: min(380px, calc(100vw - 32px));
+ padding: 16px;
+}
+.session-import-preview-popover strong { font-size: 13px; overflow-wrap: anywhere; }
+.session-import-preview-popover pre {
+ max-height: min(360px, 65vh);
+ overflow: auto;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ font: 12px / 1.6 var(--mono);
+ margin-top: 10px;
+ color: var(--ink-2);
+}
+
+.session-import-report {
+ padding-left: 24px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px 16px;
+ font-size: 12px;
+ color: var(--ink-2);
+}
+
+.session-import-preview pre {
+ max-height: 320px;
+ overflow: auto;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ font-size: 12px;
+ padding: 12px;
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: 6px;
+}
+
+.session-import-result {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ padding-block: 8px;
+ overflow-wrap: anywhere;
+}
+
+@media (max-width: 640px) {
+ .settings-view:has([data-page="migration"]) {
+ grid-template-columns: 56px minmax(0, 1fr);
+ }
+
+ .settings-view:has([data-page="migration"]) .settings-nav {
+ padding-inline: 8px;
+ }
+
+ .settings-view:has([data-page="migration"]) .settings-nav-item,
+ .settings-view:has([data-page="migration"]) .settings-back {
+ justify-content: center;
+ padding-inline: 0;
+ }
+
+ .settings-view:has([data-page="migration"]) .settings-nav-item span,
+ .settings-view:has([data-page="migration"]) .settings-back span {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+ }
+
+ .settings-view:has([data-page="migration"]) .settings-nav-title,
+ .settings-view:has([data-page="migration"]) .settings-nav-note {
+ display: none;
+ }
+
+ .settings-content[data-page="migration"] {
+ padding: 24px 16px 48px;
+ }
+
+ .session-import-project-summary { flex-wrap: wrap; }
+ .session-import-project-summary .session-import-project-path { flex-basis: calc(100% - 40px); order: 5; }
+ .session-import-project-summary strong { flex: 1; max-width: none; }
+ .session-import-project-summary > span { margin-left: 0; }
+ .session-import-header-actions { justify-content: flex-start; width: 100%; min-width: 0; }
+ .session-import-section-head > h1 { width: 100%; }
+ .session-import-header-actions .session-import-selected { width: 100%; }
+ .session-import-app-summary { display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; gap: 8px; padding: 14px; }
+ .session-import-logo-placeholder { display: none; }
+ .session-import-logo { width: 24px; height: 24px; grid-column: 2; grid-row: 1; justify-self: start; }
+ .session-import-app-info { grid-column: 2; grid-row: 1; padding-left: 32px; }
+ .session-import-app-summary > .session-import-app-count { grid-column: 2; grid-row: 2; }
+ .session-import-app-summary > .session-import-select-group { grid-column: 1; grid-row: 1 / 3; align-self: center; justify-self: center; }
+ .session-import-app-summary > .session-import-platform-confirm { grid-column: 2 / 4; grid-row: 3; justify-self: end; }
+ .session-import-app-chevron { grid-column: 3; grid-row: 1; justify-self: end; }
+ .session-import-app-body { padding: 8px 14px 12px; }
+ .session-import-source-picker { flex-wrap: wrap; }
+ .session-import-source-picker input { flex-basis: 100%; }
+ .session-import-section-head { flex-wrap: wrap; }
+ .session-import-sources { grid-template-columns: minmax(0, 1fr); }
+ .session-import-toolbar { gap: 8px; }
+}
diff --git a/apps/web/client/src/features/sidebar/overlays.css b/apps/web/client/src/features/sidebar/overlays.css
index 7fe45642..797dd056 100644
--- a/apps/web/client/src/features/sidebar/overlays.css
+++ b/apps/web/client/src/features/sidebar/overlays.css
@@ -254,6 +254,8 @@
position: absolute;
top: 50%;
right: 5px;
+ /* Centres the pencil on the row's axis, level with the group name. */
+ transform: translateY(-50%);
display: none;
place-items: center;
width: 20px;
diff --git a/apps/web/client/src/i18n/messages.ts b/apps/web/client/src/i18n/messages.ts
index 8a0ec8a1..95b2cc5b 100644
--- a/apps/web/client/src/i18n/messages.ts
+++ b/apps/web/client/src/i18n/messages.ts
@@ -169,6 +169,7 @@ const zhCN = {
themeDark: "深色",
emptyConversation: "还没有消息 — 在下方输入开始对话",
emptyTitle: "今天想推进什么?",
+ emptySessionImport: "从其他AI应用导入会话",
messagePlaceholder: "输入消息…",
markdownPreview: "预览 Markdown",
markdownEdit: "编辑 Markdown",
@@ -379,6 +380,7 @@ const zhCN = {
processThinkingElapsed: "思考 {duration}",
processCompleted: "已完成:{summary}",
processCompletedTools: "已完成 {count} 项工具操作",
+ processRecordedTools: "工具调用 {count} 项",
processJoiner: "、",
"processCount.read": "读取文件 {count} 项",
"processCount.write": "写入文件 {count} 项",
@@ -481,6 +483,7 @@ const zhCN = {
"toolAction.other.completed": "调用了 {name}",
"toolAction.other.failed": "调用 {name} 失败",
"toolStatus.pending": "等待结果",
+ "toolStatus.unknown": "历史状态未记录",
"toolStatus.completed": "完成",
"toolStatus.failed": "失败",
toolMoreLines: "… 其余 {count} 行",
@@ -711,6 +714,60 @@ const zhCN = {
settingsBuild: "当前版本",
settingsBuildMadeAt: "这个窗口正在运行的程序和它的构建时间。",
settingsBuildDev: "从源码启动,没有安装包的构建时间。",
+ importTitle: "会话迁移",
+ importSelectGroup: "全选",
+ importDeselectGroup: "取消全选",
+ importRescan: "重新扫描",
+ importNoConversation: "无可导入的对话(仅含命令或控制记录)",
+ importSourcesTitle: "从其他 AI 应用导入",
+ importHistoryTitle: "导入历史",
+ importAppCount: "{count} 个会话",
+ importChooseSource: "选择目录",
+ importBeforeScan: "扫描后将在这里显示此应用的会话",
+ importNoMatches: "没有匹配的会话,试试其他关键词或清空搜索",
+ importHistoryEmpty: "暂无导入记录",
+ importUnknownSource: "来源未记录",
+ importSubtitle: "从本机 Claude Code / Codex 导入已有会话。源文件只读,不会修改原应用的会话。",
+ importCodexRoot: "Codex 数据目录",
+ importClaudeRoot: "Claude Code projects 目录",
+ importIncludeArchived: "包含已归档会话",
+ importArchived: "已归档",
+ importScan: "扫描会话",
+ importWorking: "处理中",
+ importSearch: "搜索项目、来源或会话",
+ importSelectAll: "全选当前列表",
+ importEmpty: "没有可导入的会话",
+ importUnknownProject: "项目未记录",
+ importPreview: "预览",
+ importTarget: "目标项目目录",
+ importChooseTarget: "选择目标项目目录",
+ importCounts: "{messages} 条消息 · {tools} 次工具调用",
+ importSelected: "已选 {count} 项",
+ importScanErrors: "{count} 项扫描问题",
+ importReviewSelected: "检查所选会话",
+ importConfirm: "导入所选会话",
+ importCancel: "停止后续导入",
+ importOpen: "打开会话",
+ "importJob.running": "正在导入",
+ "importJob.completed": "导入结束",
+ "importJob.cancelled": "已停止",
+ "importJob.interrupted": "导入中断",
+ "importResult.imported": "已导入",
+ "importResult.skipped": "相同快照已存在",
+ "importResult.failed": "未导入",
+ "importWarning.attachment_missing": "附件缺失或不支持",
+ "importWarning.unsupported_content": "不支持的内容块",
+ "importWarning.tool_arguments_as_text": "工具参数按原文保留",
+ "importWarning.source_instructions_excluded": "来源控制指令未激活",
+ "importWarning.rolled_back_history": "已排除回滚历史",
+ "importWarning.encrypted_reasoning_excluded": "加密推理不可迁移",
+ "importWarning.unsupported_item": "特殊记录按历史原文保留",
+ "importWarning.source_compaction": "已保留来源压缩摘要",
+ "importWarning.claude_compaction": "仅迁移当前消息链,压缩前历史可能缺失",
+ "importWarning.child_history_excluded": "未合并子会话",
+ "importWarning.branch_parent_missing": "分支祖先记录缺失",
+ "importWarning.tool_result_missing": "工具结果未记录",
+ "importWarning.tool_call_missing": "工具调用未记录",
settingsLanguage: "界面语言",
settingsLanguageDesc: "切换后立即生效,并在下次打开时保持。",
settingsAccount: "账号",
@@ -1117,6 +1174,7 @@ const enUS: Record = {
themeDark: "Dark",
emptyConversation: "No messages yet — start a conversation below",
emptyTitle: "What should we move forward today?",
+ emptySessionImport: "Import sessions from other AI apps",
messagePlaceholder: "Type a message…",
markdownPreview: "Preview Markdown",
markdownEdit: "Edit Markdown",
@@ -1332,6 +1390,7 @@ const enUS: Record = {
processThinkingElapsed: "Thought for {duration}",
processCompleted: "Completed: {summary}",
processCompletedTools: "{count} tool operations completed",
+ processRecordedTools: "Recorded tool calls: {count}",
processJoiner: ", ",
"processCount.read": "{count} file reads",
"processCount.write": "{count} file writes",
@@ -1434,6 +1493,7 @@ const enUS: Record = {
"toolAction.other.completed": "Called {name}",
"toolAction.other.failed": "{name} failed",
"toolStatus.pending": "Waiting for result",
+ "toolStatus.unknown": "Historical status not recorded",
"toolStatus.completed": "Done",
"toolStatus.failed": "Failed",
toolMoreLines: "… {count} more lines",
@@ -1673,6 +1733,60 @@ const enUS: Record = {
settingsBuild: "Current build",
settingsBuildMadeAt: "The program this window is running, and when it was built.",
settingsBuildDev: "Running from source; an installed build would show its build time.",
+ importTitle: "Session Migration",
+ importSelectGroup: "Select all",
+ importDeselectGroup: "Deselect all",
+ importRescan: "Rescan",
+ importNoConversation: "No importable conversation (command/control records only)",
+ importSourcesTitle: "Import from other AI apps",
+ importHistoryTitle: "Import history",
+ importAppCount: "{count} sessions",
+ importChooseSource: "Choose directory",
+ importBeforeScan: "Scan to list this app's sessions",
+ importNoMatches: "No matching sessions. Try another keyword or clear the search.",
+ importHistoryEmpty: "No import records yet",
+ importUnknownSource: "Source not recorded",
+ importSubtitle: "Import local Claude Code / Codex sessions. Source files are read-only and remain unchanged.",
+ importCodexRoot: "Codex data directory",
+ importClaudeRoot: "Claude Code projects directory",
+ importIncludeArchived: "Include archived sessions",
+ importArchived: "Archived",
+ importScan: "Scan sessions",
+ importWorking: "Working",
+ importSearch: "Search projects, sources or sessions",
+ importSelectAll: "Select visible sessions",
+ importEmpty: "No importable sessions",
+ importUnknownProject: "Project not recorded",
+ importPreview: "Preview",
+ importTarget: "Destination project directory",
+ importChooseTarget: "Choose destination project directory",
+ importCounts: "{messages} messages · {tools} tool calls",
+ importSelected: "{count} selected",
+ importScanErrors: "{count} scan issues",
+ importReviewSelected: "Review selected sessions",
+ importConfirm: "Import selected sessions",
+ importCancel: "Stop remaining imports",
+ importOpen: "Open session",
+ "importJob.running": "Importing",
+ "importJob.completed": "Import finished",
+ "importJob.cancelled": "Stopped",
+ "importJob.interrupted": "Import interrupted",
+ "importResult.imported": "Imported",
+ "importResult.skipped": "Identical snapshot already exists",
+ "importResult.failed": "Not imported",
+ "importWarning.attachment_missing": "Attachment missing or unsupported",
+ "importWarning.unsupported_content": "Unsupported content block",
+ "importWarning.tool_arguments_as_text": "Tool arguments preserved as text",
+ "importWarning.source_instructions_excluded": "Source controls not activated",
+ "importWarning.rolled_back_history": "Rolled-back history excluded",
+ "importWarning.encrypted_reasoning_excluded": "Encrypted reasoning not portable",
+ "importWarning.unsupported_item": "Special item preserved as historical text",
+ "importWarning.source_compaction": "Source compaction summary retained",
+ "importWarning.claude_compaction": "Only the current chain is imported; pre-compaction history may be missing",
+ "importWarning.child_history_excluded": "Child sessions not merged",
+ "importWarning.branch_parent_missing": "Branch ancestor missing",
+ "importWarning.tool_result_missing": "Tool result not recorded",
+ "importWarning.tool_call_missing": "Tool call not recorded",
settingsLanguage: "Language",
settingsLanguageDesc: "Applies right away and is remembered next time.",
settingsAccount: "Account",
diff --git a/apps/web/client/src/lib/imported-content.ts b/apps/web/client/src/lib/imported-content.ts
new file mode 100644
index 00000000..1fa2dccd
--- /dev/null
+++ b/apps/web/client/src/lib/imported-content.ts
@@ -0,0 +1,60 @@
+const CONTROL_TAGS = [
+ "system-reminder",
+ "environment_context",
+ "permissions instructions",
+ "user_instructions",
+ "developer_instructions",
+ "turn_aborted",
+ "request_id",
+ "turn_id",
+ "model",
+ "command-name",
+ "command-message",
+ "command-args",
+ "local-command-caveat",
+ "local-command-stdout",
+ "local-command-stderr",
+];
+const PREFIX = new RegExp(`^\\s*<(${CONTROL_TAGS.join("|")})(?:\\s[^>]*)?>`);
+
+function unwrapUserQuery(value: string): string {
+ const start = /^\s*]*)?>/.exec(value);
+ if (!start) return value;
+ const tokens = /<\/?user_query(?:\s[^>]*)?>/g;
+ tokens.lastIndex = start[0].length;
+ let depth = 1;
+ for (let token = tokens.exec(value); token; token = tokens.exec(value)) {
+ depth += token[0].startsWith("") ? -1 : 1;
+ if (depth === 0) {
+ if (value.slice(tokens.lastIndex).trim()) return value;
+ return value.slice(start[0].length, token.index);
+ }
+ }
+ return value;
+}
+
+/** Remove source-host envelopes and unwrap complete user payloads, not fenced examples. */
+export function stripImportedUserControls(value: string): { text: string; removed: boolean } {
+ let text = value;
+ let removed = false;
+ for (;;) {
+ const first = PREFIX.exec(text);
+ if (!first) return { text: unwrapUserQuery(text), removed };
+ removed = true;
+ const tag = first[1];
+ const tokens = new RegExp(`<\\/?${tag}(?:\\s[^>]*)?>`, "g");
+ tokens.lastIndex = first[0].length;
+ let depth = 1;
+ let end = -1;
+ for (let token = tokens.exec(text); token; token = tokens.exec(text)) {
+ depth += token[0].startsWith("") ? -1 : 1;
+ if (depth === 0) {
+ end = tokens.lastIndex;
+ break;
+ }
+ }
+ // A partial source envelope is not a trustworthy user prompt.
+ if (end < 0) return { text: "", removed };
+ text = text.slice(end).trimStart();
+ }
+}
diff --git a/apps/web/client/src/state/session.ts b/apps/web/client/src/state/session.ts
index 269c5586..8a3e74e9 100644
--- a/apps/web/client/src/state/session.ts
+++ b/apps/web/client/src/state/session.ts
@@ -13,6 +13,7 @@ import { api, apiSend, errorMessage, setUnauthorizedHandler } from "../lib/api";
import { splitAttachedFiles } from "../lib/attached-files";
import { writeClipboard } from "../lib/clipboard";
import { basename, clipDetail, compact, toolArgs, toolInputDetail, toolInputSummary } from "../lib/format";
+import { stripImportedUserControls } from "../lib/imported-content";
import { contentText, skillInvocation } from "../lib/markdown";
import { FULL_ACCESS_PRESET, isFullAccessRisk } from "../lib/permissions";
import { isDesktopHost } from "../platform/host";
@@ -513,8 +514,10 @@ export function timelineFromEntries(entries: readonly SessionEntry[] = [], retry
if (duplicate) continue;
}
const index = messagePosition.get(entry.id) ?? 0;
- const text = contentText(message.content);
+ const rawText = contentText(message.content);
+ const text = entry.imported && message.role === "user" ? stripImportedUserControls(rawText).text : rawText;
const images = message.role === "user" ? imageSources(message.content) : [];
+ if (entry.imported && message.role === "user" && !text && !images.length) continue;
// A failed request leaves an assistant message with no content; without a row the error was invisible.
const error =
message.role === "assistant" && message.stopReason === "error"
@@ -578,12 +581,21 @@ export function timelineFromEntries(entries: readonly SessionEntry[] = [], retry
continue;
}
const result = toolResults.get(String(block.id));
- const change = fileChange(String(block.name ?? ""), block.arguments, result);
- const links = childAgents(String(block.name ?? ""), result?.details);
+ const imported = Boolean(entry.imported);
+ const importedUnknown =
+ imported &&
+ (!result ||
+ (result.details &&
+ typeof result.details === "object" &&
+ "importedStatus" in result.details &&
+ result.details.importedStatus === "unknown"));
+ const change = imported ? undefined : fileChange(String(block.name ?? ""), block.arguments, result);
+ const links = imported ? {} : childAgents(String(block.name ?? ""), result?.details);
items.push({
itemId: `tool-${block.id}`,
kind: "tool",
- state: result?.isError ? "failed" : result ? "completed" : "pending",
+ state: importedUnknown ? "unknown" : result?.isError ? "failed" : result ? "completed" : "pending",
+ ...(imported ? { imported: true } : {}),
toolName: String(block.name ?? ""),
inputSummary: toolInputSummary(block.arguments),
outputSummary: result ? compact(contentText(result.content)) : "",
@@ -594,9 +606,9 @@ export function timelineFromEntries(entries: readonly SessionEntry[] = [], retry
...links,
// The proposal comes from the persisted result, not the live event, so
// a snapshot refresh (which follows every finished message) keeps the
- // review card. Without this it flashed once and vanished.
- ...(block.name === "exit_plan_mode" ? planReview(result?.details) : {}),
- ...(block.name === "subagent"
+ // review card. Imported calls are history, never native review requests.
+ ...(!imported && block.name === "exit_plan_mode" ? planReview(result?.details) : {}),
+ ...(!imported && block.name === "subagent"
? {
subagentTasks: subagentTasks(
block.arguments,
@@ -1533,8 +1545,8 @@ export async function refreshWorkspaces(): Promise {
setState({ workspaces: data.workspaces });
}
-/** Open a session and follow its stream (legacy `openSession`, 3122-3130). */
-export async function openSession(id: string): Promise {
+/** Open a session and follow its stream; optional discovery links can suppress technical error toasts. */
+export async function openSession(id: string, options: { reportErrors?: boolean } = {}): Promise {
choseSession = true;
// The draft this page left behind is gone: it was never a session, so its
// placeholder row must not survive under the workspace it named.
@@ -1547,7 +1559,7 @@ export async function openSession(id: string): Promise {
renderSnapshot(snapshot);
connectStream(id);
} catch (error) {
- if (getState().ui.openingSession === id) showError(errorMessage(error));
+ if (getState().ui.openingSession === id && options.reportErrors !== false) showError(errorMessage(error));
} finally {
if (getState().ui.openingSession === id) setUi({ openingSession: null });
}
diff --git a/apps/web/client/src/state/store.ts b/apps/web/client/src/state/store.ts
index a8ceaed4..2e4a1804 100644
--- a/apps/web/client/src/state/store.ts
+++ b/apps/web/client/src/state/store.ts
@@ -148,7 +148,7 @@ export interface PreviewState {
}
/** Pages of the settings view, in menu order. */
-export type SettingsPage = "general" | "account" | "appearance" | "models" | "plugins" | "mcp" | "skills";
+export type SettingsPage = "general" | "account" | "appearance" | "models" | "plugins" | "mcp" | "skills" | "migration";
/**
* What a settings editor edits. `mcp` with a name edits that server, without one
diff --git a/apps/web/client/src/state/types.ts b/apps/web/client/src/state/types.ts
index 5faec065..d7a97791 100644
--- a/apps/web/client/src/state/types.ts
+++ b/apps/web/client/src/state/types.ts
@@ -151,6 +151,44 @@ export interface SessionEntry {
readonly [key: string]: unknown;
}
+export interface ImportCandidate {
+ readonly id: string;
+ readonly source: "codex" | "claude-code" | "workbuddy" | "qoder";
+ readonly sessionId: string;
+ readonly title: string;
+ readonly cwd: string;
+ readonly modified: string;
+ readonly archived: boolean;
+ readonly version: string;
+ readonly sourcePath?: string;
+}
+
+export interface ImportPreview extends ImportCandidate {
+ readonly fingerprint: string;
+ readonly messageCount: number;
+ readonly toolCount: number;
+ readonly warnings: readonly { readonly code: string; readonly count: number }[];
+ readonly excerpt: string;
+}
+
+export interface ImportJob {
+ readonly id: string;
+ readonly createdAt: string;
+ readonly status: "running" | "completed" | "cancelled" | "interrupted";
+ readonly total: number;
+ readonly results: readonly {
+ readonly candidateId: string;
+ readonly source?: "codex" | "claude-code" | "workbuddy" | "qoder";
+ readonly sourcePath?: string;
+ readonly path?: string;
+ readonly title?: string;
+ readonly status: "imported" | "skipped" | "failed";
+ readonly sessionId?: string;
+ readonly error?: string;
+ readonly warning?: string;
+ }[];
+}
+
/** A row of GET /api/v1/sessions. Snapshots are merged into these rows too. */
export interface SessionSummary {
readonly id: string;
@@ -410,7 +448,7 @@ export interface MessageTimelineItem {
readonly promptCommand?: boolean;
}
-export type ToolState = "pending" | "completed" | "failed";
+export type ToolState = "pending" | "completed" | "failed" | "unknown";
export interface ThinkingTiming {
readonly startedAt: number;
@@ -430,6 +468,7 @@ export interface ThinkingTimelineItem {
}
export interface ToolTimelineItem {
+ readonly imported?: boolean;
readonly itemId: string;
readonly kind: "tool";
readonly state: ToolState;
diff --git a/apps/web/openapi.yaml b/apps/web/openapi.yaml
index ad71b34b..966c08f5 100644
--- a/apps/web/openapi.yaml
+++ b/apps/web/openapi.yaml
@@ -6,6 +6,122 @@ info:
servers:
- url: http://127.0.0.1:3077
paths:
+ /api/v1/session-imports/scan:
+ post:
+ description: Read-only discovery on the Host machine. Archived directories and child sessions are excluded by default. Symlinks are not traversed.
+ requestBody:
+ content:
+ application/json:
+ schema:
+ type: object
+ additionalProperties: false
+ description: Scans configured default source directories only. Custom path overrides are rejected.
+ properties:
+ includeArchived: { type: boolean, default: false }
+ responses:
+ "200":
+ description: Candidates with opaque IDs and per-source scan errors
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [existingCount, roots, candidates, errors]
+ properties:
+ existingCount: { type: integer, minimum: 0, description: "Matching sessions already in the current candidate list." }
+ roots:
+ type: object
+ description: Actual resolved source directories, including empty scanned directories.
+ additionalProperties: { type: string }
+ candidates: { type: array, items: { $ref: "#/components/schemas/ImportCandidate" } }
+ errors:
+ type: array
+ items:
+ type: object
+ required: [source, message]
+ properties:
+ source: { type: string }
+ message: { type: string }
+ "400": { description: Invalid input }
+ /api/v1/session-imports/preview:
+ post:
+ description: Parse and retain an immutable read-only snapshot for 15 minutes (subject to bounded cache eviction and host restart). Does not create a session, start an agent or invoke a model.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [candidateId]
+ properties:
+ candidateId: { type: string }
+ responses:
+ "200":
+ description: Snapshot fingerprint, bounded excerpt and compatibility warnings
+ content:
+ application/json:
+ schema: { $ref: "#/components/schemas/ImportPreview" }
+ "400": { description: Unsupported or invalid source }
+ "409": { description: Candidate expired or source changed }
+ /api/v1/session-imports:
+ get:
+ description: List persisted import jobs. Previous-host unfinished jobs are marked interrupted.
+ responses:
+ "200":
+ description: Job history
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ jobs: { type: array, items: { $ref: "#/components/schemas/ImportJob" } }
+ post:
+ description: Import the exact retained preview snapshots, not later source changes, as new native sessions. Missing or expired previews require a new preview (409). Submitted snapshots are pinned until settlement. Identical source snapshots are skipped; newly previewed changed snapshots create new copies.
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [selections]
+ properties:
+ selections:
+ type: array
+ minItems: 1
+ maxItems: 100
+ items:
+ type: object
+ required: [candidateId, fingerprint, cwd]
+ properties:
+ candidateId: { type: string }
+ fingerprint: { type: string, pattern: "^[a-f0-9]{64}$" }
+ cwd: { type: string, description: Existing absolute destination project directory }
+ responses:
+ "202":
+ description: Accepted job; poll the job resource for per-session results
+ content:
+ application/json:
+ schema: { $ref: "#/components/schemas/ImportJob" }
+ "400": { description: Invalid selection }
+ /api/v1/session-imports/{jobId}:
+ parameters:
+ - { name: jobId, in: path, required: true, schema: { type: string, format: uuid } }
+ get:
+ responses:
+ "200":
+ description: Current or persisted job
+ content:
+ application/json:
+ schema: { $ref: "#/components/schemas/ImportJob" }
+ "404": { description: Unknown job }
+ delete:
+ description: Request cancellation between sessions. Already published sessions remain; poll until the job settles.
+ responses:
+ "200":
+ description: Current job
+ content:
+ application/json:
+ schema: { $ref: "#/components/schemas/ImportJob" }
+ "404": { description: Unknown job }
/api/v1/terminals:
get:
description: List Host-owned terminal metadata for a known workspace. Requires the Host cookie and allowed Origin; never starts a shell.
@@ -2160,6 +2276,65 @@ paths:
"200": { description: Workspace file contents }
components:
schemas:
+ ImportCandidate:
+ type: object
+ required: [id, source, sessionId, title, cwd, modified, archived, version]
+ properties:
+ id: { type: string }
+ source: { type: string, enum: [codex, claude-code, workbuddy, qoder] }
+ sessionId: { type: string }
+ title:
+ type: string
+ description: Readable bounded scan title; explicit source title, first user text, or project/source plus ID fallback. Preview may refine a fallback.
+ cwd: { type: string, description: Source project directory from discovered metadata }
+ sourcePath: { type: string, description: Absolute source JSONL path on the host }
+ modified: { type: string, format: date-time }
+ archived: { type: boolean }
+ version: { type: string }
+ ImportPreview:
+ allOf:
+ - { $ref: "#/components/schemas/ImportCandidate" }
+ - type: object
+ required: [fingerprint, messageCount, toolCount, warnings, excerpt]
+ properties:
+ fingerprint: { type: string }
+ messageCount: { type: integer }
+ toolCount: { type: integer }
+ excerpt: { type: string, maxLength: 12000 }
+ warnings:
+ type: array
+ items:
+ type: object
+ required: [code, count]
+ properties:
+ code: { type: string }
+ count: { type: integer }
+ ImportJob:
+ type: object
+ required: [id, createdAt, status, total, results]
+ properties:
+ id: { type: string, format: uuid }
+ createdAt: { type: string, format: date-time }
+ status: { type: string, enum: [running, completed, cancelled, interrupted] }
+ total: { type: integer }
+ results:
+ type: array
+ items:
+ type: object
+ required: [candidateId, status]
+ properties:
+ candidateId: { type: string }
+ source: { type: string, enum: [codex, claude-code, workbuddy, qoder], description: Source client; legacy recovery uses validated provenance only }
+ sourcePath: { type: string, description: Absolute source JSONL path on the host when known }
+ title: { type: string, description: Reviewed title or bounded native-header recovery for older results; absent when unknown }
+ cwd:
+ type: string
+ description: Selected absolute destination for imported, skipped or failed selections. Older results may recover native header cwd; absent when unknown or for batch-level journal failures.
+ status: { type: string, enum: [imported, skipped, failed] }
+ sessionId: { type: string }
+ path: { type: string }
+ error: { type: string }
+ warning: { type: string, description: "Session publication succeeded, but a secondary notification failed" }
TerminalInfo:
type: object
required: [id, cwd, title, shell, status, cols, rows]
diff --git a/apps/web/src/runtime.ts b/apps/web/src/runtime.ts
index a02694b6..3a95ddd8 100644
--- a/apps/web/src/runtime.ts
+++ b/apps/web/src/runtime.ts
@@ -634,7 +634,10 @@ function goalRoundOf(entry: SessionEntry): number | undefined {
export function projectEntry(entry: SessionEntry): Record {
// Only assistant turns carry usage; other message kinds have none to report.
- const usage = entry.type === "message" && entry.message.role === "assistant" ? entry.message.usage : undefined;
+ const usage =
+ entry.type === "message" && entry.message.role === "assistant" && !entry.imported
+ ? entry.message.usage
+ : undefined;
const notification =
entry.type === "custom_message" ? projectSubagentNotification(entry.customType, entry.details) : undefined;
// The command echo's text rides the custom entry's data.
@@ -644,6 +647,7 @@ export function projectEntry(entry: SessionEntry): Record {
id: entry.id,
type: entry.type,
timestamp: entry.timestamp,
+ ...(entry.imported ? { imported: entry.imported } : {}),
...(entry.type === "message" ? { message: entry.message } : {}),
...(usage ? { usage } : {}),
...(entry.type === "custom_message" ? { content: entry.content, customType: entry.customType } : {}),
diff --git a/apps/web/src/server.ts b/apps/web/src/server.ts
index 59d75593..09e6adc6 100644
--- a/apps/web/src/server.ts
+++ b/apps/web/src/server.ts
@@ -17,6 +17,9 @@ import {
resolveStepAgentDir,
resolveStepCodeVersion,
resolveStepConfigRoot,
+ resolveStepSessionDir,
+ SessionImportError,
+ SessionImportService,
SessionSearchRequestError,
} from "@step-harness/coding-agent";
import { AccountProfileCache } from "./account.ts";
@@ -295,6 +298,11 @@ configureStepMcpPool({ quitRelease: "idle" });
const mcpWarmup = createStepMcpHostWarmup();
const resyncMcp = (): void => void mcpWarmup.sync(defaultWorkspace());
const sessionSearch = new WebSessionSearch(resolveStepAgentDir());
+const sessionImports = new SessionImportService({
+ agentDir: resolveStepAgentDir(),
+ sessionRoot: resolveStepSessionDir(),
+ onPersisted: (event) => sessionSearch.notifyPersisted(event),
+});
const store = new WebSessionStore({ index: sessionSearch });
const workspaces = new WorkspaceRegistry(join(resolveStepAgentDir(), WORKSPACES_FILE));
const embeddedBrowser = new WebBrowser([port, launchPort]);
@@ -874,6 +882,60 @@ const server = createServer(async (request, response) => {
if (parts[2] === "workspace" && parts[3] === "git") {
return await gitRoute(parts.slice(4).map(decodeURIComponent), method, url, request, response);
}
+ if (parts[2] === "session-imports") {
+ try {
+ if (parts.length === 3 && method === "GET")
+ return json(response, 200, { jobs: await sessionImports.listJobs() });
+ if (parts.length === 4 && parts[3] === "scan" && method === "POST") {
+ const input = await body(request);
+ for (const key of Object.keys(input))
+ if (key !== "includeArchived")
+ return error(response, 400, "Only default-directory scanning is supported");
+ if (input.includeArchived !== undefined && typeof input.includeArchived !== "boolean")
+ return error(response, 400, "includeArchived must be a boolean");
+ return json(
+ response,
+ 200,
+ await sessionImports.scan({
+ includeArchived: input.includeArchived === true,
+ }),
+ );
+ }
+ if (parts.length === 4 && parts[3] === "preview" && method === "POST") {
+ const input = await body(request);
+ if (typeof input.candidateId !== "string") return error(response, 400, "candidateId is required");
+ return json(response, 200, await sessionImports.preview(input.candidateId));
+ }
+ if (parts.length === 3 && method === "POST") {
+ const input = await body(request);
+ if (!Array.isArray(input.selections)) return error(response, 400, "selections are required");
+ const selections: { candidateId: string; fingerprint: string; cwd: string }[] = [];
+ for (const value of input.selections) {
+ if (
+ !value ||
+ typeof value !== "object" ||
+ !("candidateId" in value) ||
+ typeof value.candidateId !== "string" ||
+ !("fingerprint" in value) ||
+ typeof value.fingerprint !== "string" ||
+ !("cwd" in value) ||
+ typeof value.cwd !== "string"
+ )
+ return error(response, 400, "Invalid import selection");
+ selections.push({ candidateId: value.candidateId, fingerprint: value.fingerprint, cwd: value.cwd });
+ }
+ return json(response, 202, await sessionImports.start(selections));
+ }
+ if (parts.length === 4 && method === "GET")
+ return json(response, 200, await sessionImports.getJob(parts[3]));
+ if (parts.length === 4 && method === "DELETE")
+ return json(response, 200, await sessionImports.cancel(parts[3]));
+ return error(response, 404, "Not found");
+ } catch (caught) {
+ if (caught instanceof SessionImportError) return error(response, caught.status, caught.message);
+ return error(response, 400, caught instanceof Error ? caught.message : String(caught));
+ }
+ }
if (parts[2] === "settings" && parts[3]) {
return await settingsRoute(parts.slice(3).map(decodeURIComponent), method, url, request, response);
}
@@ -1275,6 +1337,7 @@ const shutdown = async (): Promise => {
voice.stopAll(),
(async () => {
try {
+ await sessionImports.close();
await store.close();
} finally {
// Flush sessions before stopping their index, even if session teardown fails.
diff --git a/apps/web/src/turn-changes.ts b/apps/web/src/turn-changes.ts
index 4542155d..e55fd1b0 100644
--- a/apps/web/src/turn-changes.ts
+++ b/apps/web/src/turn-changes.ts
@@ -261,7 +261,7 @@ export function turnChanges(entries: readonly SessionEntry[], turnId: string): T
const calls = new Map();
for (const entry of entries.slice(start + 1)) {
if (isUserMessage(entry)) break;
- if (entry.type !== "message") continue;
+ if (entry.type !== "message" || entry.imported) continue;
const message = entry.message as {
role: string;
content?: unknown;
@@ -529,6 +529,7 @@ async function replayFile(
const calls = new Map();
let content: string | undefined;
for (const entry of entries.slice(0, end)) {
+ if (entry.imported) continue;
if (entry.type === "custom_message" && entry.customType === TURN_REVERT_TYPE) {
const turnId = (entry.details as { turnId?: unknown } | undefined)?.turnId;
if (typeof turnId !== "string") continue;
diff --git a/apps/web/test/client-empty-session-style.test.ts b/apps/web/test/client-empty-session-style.test.ts
index 3873b9eb..d1565720 100644
--- a/apps/web/test/client-empty-session-style.test.ts
+++ b/apps/web/test/client-empty-session-style.test.ts
@@ -43,6 +43,22 @@ test("the stylesheet matches the markup the empty state renders", () => {
assert.doesNotMatch(css, /\.empty-wordmark/u, "the retired wordmark styles");
});
+test("migration stays below the centred mark and question without an application subtitle", async () => {
+ assert.match(rule(".empty-conversation"), /flex-direction:\s*column/u);
+ assert.match(rule(".empty-conversation"), /align-items:\s*center/u);
+ assert.match(rule(".empty-state"), /flex:\s*0 0 auto/u);
+ assert.match(
+ component,
+ /className="empty-conversation">\s*[\s\S]*?
[\s\S]*?<\/h1>\s*<\/div>\s*\{draft/u,
+ );
+ assert.doesNotMatch(component, /emptySessionImportSources/u);
+ assert.doesNotMatch(css, /\.empty-session-import small/u);
+ const messages = await readFile(new URL("../client/src/i18n/messages.ts", import.meta.url), "utf8");
+ assert.match(messages, /emptySessionImport: "从其他AI应用导入会话"/u);
+ assert.match(messages, /emptySessionImport: "Import sessions from other AI apps"/u);
+ assert.doesNotMatch(messages, /emptySessionImportSources/u);
+});
+
test("startup is not drawn as a centred new conversation", () => {
// Before bootstrap picks a session the timeline is empty, but that is loading,
// not a new conversation: painting it centred moved the composer into the middle
diff --git a/apps/web/test/client-imported-content.test.ts b/apps/web/test/client-imported-content.test.ts
new file mode 100644
index 00000000..2a60ce2c
--- /dev/null
+++ b/apps/web/test/client-imported-content.test.ts
@@ -0,0 +1,36 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { stripImportedUserControls } from "../client/src/lib/imported-content.ts";
+import { timelineFromEntries } from "../client/src/state/session.ts";
+
+const wrapped = "你好 ";
+
+test("imported user_query wrappers retain their actual text", () => {
+ assert.deepEqual(stripImportedUserControls(wrapped), { text: "你好", removed: false });
+ assert.deepEqual(
+ stripImportedUserControls(`Source controls ${wrapped}`),
+ { text: "你好", removed: true },
+ );
+});
+
+test("code examples and incomplete user_query markup are not stripped", () => {
+ for (const text of ["```xml\n你好 \n```", "example: 你好 ", "unfinished"]) {
+ assert.equal(stripImportedUserControls(text).text, text);
+ }
+ assert.equal(
+ stripImportedUserControls("outer inner ").text,
+ "outer inner ",
+ );
+});
+
+test("existing imported messages unwrap without changing ordinary native messages", () => {
+ for (const source of ["codex", "claude-code", "workbuddy", "qoder"]) {
+ const rows = timelineFromEntries([
+ { id: "old", type: "message", imported: { source, sessionId: "s", itemId: "u" }, message: { role: "user", content: wrapped } },
+ { id: "native", type: "message", message: { role: "user", content: wrapped } },
+ ]);
+ assert.equal(rows[0].kind, "message");
+ assert.equal(rows[0].kind === "message" && rows[0].content, "你好");
+ assert.equal(rows[1].kind === "message" && rows[1].content, wrapped);
+ }
+});
diff --git a/apps/web/test/client-process-groups.test.ts b/apps/web/test/client-process-groups.test.ts
index 1b86a367..61daa599 100644
--- a/apps/web/test/client-process-groups.test.ts
+++ b/apps/web/test/client-process-groups.test.ts
@@ -141,6 +141,37 @@ test("unknown tool names are counted without using text to infer success or cate
assert.equal(group.running, false);
});
+test("imported unknown tool calls form a non-running group with a neutral summary", () => {
+ const items = [
+ { ...tool("shell", "exec_command", "unknown"), imported: true },
+ ...Array.from({ length: 10 }, (_, index) => ({ ...tool(`write-${index}`, "Write", "unknown"), imported: true })),
+ ];
+ const before = structuredClone(items);
+ for (const active of [false, true]) {
+ const group = onlyGroup(items, active);
+ assert.equal(group.rows.length, 11);
+ assert.equal(group.running, false);
+ assert.deepEqual(group.rows.map((row) => row.index), Array.from({ length: 11 }, (_, index) => index));
+ assert.deepEqual(processGroupSummary(group, zh), { label: "工具调用 11 项", detail: "", icon: "wrench" });
+ assert.equal(processGroupSummary(group, en).label, "Recorded tool calls: 11");
+ }
+ assert.deepEqual(items, before);
+ const mixed = onlyGroup([items[0], tool("completed")]);
+ assert.equal(processGroupSummary(mixed, zh).label, "工具调用 2 项");
+});
+
+test("historical grouping does not hide native unknown calls or cross failed and user boundaries", () => {
+ const imported = { ...tool("imported", "Write", "unknown"), imported: true };
+ const nativeUnknown = tool("native", "write", "unknown");
+ assert.ok(!("kind" in groupProcessRows([nativeUnknown], false)[0]));
+ for (const boundary of [user, { ...tool("failed", "Write", "failed"), imported: true }]) {
+ const rows = groupProcessRows([imported, boundary, { ...imported, itemId: "next" }], false);
+ assert.equal(rows.length, 3);
+ assert.ok(!("kind" in rows[1]));
+ assert.equal(rows[1].item, boundary);
+ }
+});
+
test("regrouping a streaming turn keeps the objects of unchanged history rows and groups", () => {
const history: TimelineItem[] = [user, thought("t1"), tool("r1"), prose, { ...user, itemId: "u2" }];
const before = groupProcessRows([...history, thought("t2", true)], true);
diff --git a/apps/web/test/client-session-import-history.test.ts b/apps/web/test/client-session-import-history.test.ts
new file mode 100644
index 00000000..69d22e0f
--- /dev/null
+++ b/apps/web/test/client-session-import-history.test.ts
@@ -0,0 +1,104 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { displayableImportCandidates, pendingImportCandidates } from "../client/src/features/settings/session-import-history.ts";
+import type { ImportCandidate, ImportJob, ImportPreview } from "../client/src/state/types.ts";
+
+function candidate(overrides: Partial = {}): ImportCandidate {
+ return {
+ id: "current-candidate",
+ source: "codex",
+ sessionId: "source-session",
+ title: "Source conversation",
+ cwd: "/project",
+ modified: "2026-10-27T00:00:00.000Z",
+ archived: false,
+ version: "test",
+ sourcePath: "/source/rollout.jsonl",
+ ...overrides,
+ };
+}
+
+function job(
+ results: ImportJob["results"],
+ status: ImportJob["status"] = "completed",
+): ImportJob {
+ return { id: "job", createdAt: "2026-10-27T00:00:00.000Z", status, total: results.length, results };
+}
+
+const result = {
+ candidateId: "previous-scan-candidate",
+ source: "codex" as const,
+ sourcePath: "/source/rollout.jsonl",
+ status: "imported" as const,
+ sessionId: "target-session",
+};
+
+test("successful and already-existing imports leave only unimported candidates", () => {
+ const imported = candidate();
+ const pending = candidate({ id: "pending", sourcePath: "/source/pending.jsonl" });
+ for (const status of ["imported", "skipped"] as const) {
+ assert.deepEqual(pendingImportCandidates([imported, pending], [job([{ ...result, status }])]), [pending]);
+ }
+});
+
+test("persistent source identity hides imports after rescan, reopen, and source append", () => {
+ const rescanned = candidate({ id: "new-scan-id", modified: "2026-10-28T00:00:00.000Z" });
+ const restoredJobs = JSON.parse(JSON.stringify([job([result])])) as ImportJob[];
+ assert.deepEqual(pendingImportCandidates([rescanned], restoredJobs), []);
+});
+
+test("failed results and unprocessed cancelled selections remain retryable", () => {
+ const pending = candidate();
+ assert.deepEqual(pendingImportCandidates([pending], [job([{ ...result, status: "failed" }])]), [pending]);
+ assert.deepEqual(pendingImportCandidates([pending], [job([], "cancelled")]), [pending]);
+});
+
+test("successful results leave the list before their running batch settles", () => {
+ for (const status of ["running", "cancelled", "interrupted"] as const) {
+ assert.deepEqual(pendingImportCandidates([candidate()], [job([result], status)]), []);
+ }
+});
+
+test("matching titles, other applications, and other source paths are not hidden", () => {
+ const otherSource = candidate({ id: "other-app", source: "qoder" });
+ const otherPath = candidate({ id: "other-file", sourcePath: "/source/other.jsonl" });
+ assert.deepEqual(pendingImportCandidates([otherSource, otherPath], [job([result])]), [otherSource, otherPath]);
+});
+
+test("legacy results without stable paths match only the current candidate ID", () => {
+ const current = candidate();
+ assert.deepEqual(pendingImportCandidates([current], [job([{ candidateId: current.id, status: "imported" }])]), []);
+ assert.deepEqual(pendingImportCandidates([current], [job([{ candidateId: "old-id", status: "imported" }])]), [current]);
+});
+
+function preview(value: ImportCandidate): ImportPreview {
+ return { ...value, fingerprint: "fixture", messageCount: 1, toolCount: 0, warnings: [], excerpt: value.title };
+}
+
+test("discovery only displays successfully previewed candidates", () => {
+ const healthy = candidate();
+ const failed = candidate({ id: "failed", sourcePath: "/source/bad.jsonl" });
+ const previews = new Map([[healthy.id, preview(healthy)]]);
+ assert.deepEqual(displayableImportCandidates([healthy, failed], [], previews), [healthy]);
+ assert.deepEqual(displayableImportCandidates([healthy, failed], [], new Map()), []);
+});
+
+test("search without matches is empty and only matches reviewed candidate metadata", () => {
+ const healthy = candidate();
+ const previews = new Map([[healthy.id, preview(healthy)]]);
+ assert.deepEqual(displayableImportCandidates([healthy], [], previews, "not-present"), []);
+ assert.deepEqual(displayableImportCandidates([healthy], [], previews, " ROLLOUT.JSONL "), [healthy]);
+ assert.deepEqual(displayableImportCandidates([healthy], [job([result])], previews), []);
+});
+
+test("removing a stale preview quietly removes the candidate", () => {
+ const healthy = candidate();
+ const previews = new Map([[healthy.id, preview(healthy)]]);
+ previews.delete(healthy.id);
+ assert.deepEqual(displayableImportCandidates([healthy], [], previews), []);
+});
+
+test("a later failed retry does not restore an already imported source", () => {
+ const jobs = [job([{ ...result, status: "failed" }]), job([result])];
+ assert.deepEqual(pendingImportCandidates([candidate()], jobs), []);
+});
diff --git a/apps/web/test/client-session-open-errors.test.ts b/apps/web/test/client-session-open-errors.test.ts
new file mode 100644
index 00000000..6f42adf7
--- /dev/null
+++ b/apps/web/test/client-session-open-errors.test.ts
@@ -0,0 +1,28 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { openSession } from "../client/src/state/session.ts";
+import { createInitialState, getState, setState } from "../client/src/state/store.ts";
+
+for (const reportErrors of [false, true]) {
+ test(`session open ${reportErrors ? "reports errors by default" : "can quietly refuse an unavailable import"}`, async (t) => {
+ t.mock.timers.enable({ apis: ["setTimeout"] });
+ setState({ ...createInitialState(), current: { id: "existing", cwd: "/project", status: "idle" } });
+ t.after(() => setState(createInitialState()));
+ t.mock.method(globalThis, "fetch", async () => Response.json({ error: "technical open diagnostic" }, { status: 500 }));
+ if (reportErrors) await openSession("unavailable");
+ else await openSession("unavailable", { reportErrors: false });
+ assert.equal(getState().current?.id, "existing");
+ assert.equal(getState().ui.openingSession, null);
+ assert.equal(getState().toast?.message, reportErrors ? "technical open diagnostic" : undefined);
+ });
+}
+
+test("quiet migration session opening still reports expired authentication globally", async (t) => {
+ setState(createInitialState());
+ t.after(() => setState(createInitialState()));
+ t.mock.method(globalThis, "fetch", async () => Response.json({ error: "unauthorized" }, { status: 401 }));
+ await openSession("unavailable", { reportErrors: false });
+ assert.equal(getState().ui.authExpired, true);
+ assert.equal(getState().toast, null);
+ assert.equal(getState().ui.openingSession, null);
+});
diff --git a/apps/web/test/e2e/process-groups.e2e.mjs b/apps/web/test/e2e/process-groups.e2e.mjs
index 43318979..faf26dc9 100644
--- a/apps/web/test/e2e/process-groups.e2e.mjs
+++ b/apps/web/test/e2e/process-groups.e2e.mjs
@@ -1,10 +1,11 @@
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
-import { mkdir } from "node:fs/promises";
+import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { after, before, test } from "node:test";
import { fileURLToPath } from "node:url";
+import { SessionImportService, SessionManager } from "@step-harness/coding-agent";
import { chromium } from "playwright-core";
import { createServer } from "vite";
@@ -26,10 +27,12 @@ import "/src/features/conversation/stream.css";
import {IconSprite} from "/src/ui";
import {ConversationStream} from "/src/features/conversation/ConversationStream.tsx";
import {setState} from "/src/state/store.ts";
+import {timelineFromEntries} from "/src/state/session.ts";
window.fixture = (items, running = false) => setState({
- current:{id:"singleton-test",cwd:"/fixture",status:running?"running":"idle",entries:[]},
+ booted:true,current:{id:"singleton-test",cwd:"/fixture",status:running?"running":"idle",entries:[]},
timelineItems:items
});
+window.importedFixture = entries => window.fixture(timelineFromEntries(entries));
createRoot(document.getElementById("root")).render(<> >);
`;
let vite;
@@ -78,6 +81,62 @@ const command = {
outputSummary: "fixture output",
};
+async function importedToolEntries(source) {
+ const root = await mkdtemp(join(tmpdir(), "stepcode-imported-tool-groups-"));
+ const sourceDir = join(root, "source");
+ await mkdir(sourceDir);
+ const timestamp = "2026-10-27T00:00:00.000Z";
+ const sessionId = `groups-${source}`;
+ const records = source === "codex" ? [
+ { type: "session_meta", timestamp, payload: { id: sessionId, cwd: root } },
+ { type: "response_item", timestamp, payload: { type: "message", role: "user", content: [{ type: "input_text", text: "Historical tools" }] } },
+ ] : [
+ { type: "workspace-directories", sessionId },
+ { type: "user", uuid: "user", parentUuid: null, sessionId, cwd: root, timestamp, message: { content: "Historical tools" } },
+ ];
+ let parentUuid = "user";
+ for (let index = 0; index < 11; index++) {
+ const id = `call-${index}`;
+ const args = index
+ ? { [source === "qoder" ? "file_path" : "path"]: `水果${String(index).padStart(2, "0")}.md`, content: "Historical file content" }
+ : { [source === "codex" ? "cmd" : "command"]: "ls -la" };
+ if (source === "codex") {
+ records.push(
+ { type: "response_item", timestamp, payload: { type: "function_call", call_id: id, name: index ? "write_file" : "exec_command", arguments: JSON.stringify(args) } },
+ { type: "response_item", timestamp, payload: { type: "function_call_output", call_id: id, output: `Historical result ${index}` } },
+ );
+ } else {
+ records.push(
+ { type: "assistant", uuid: `assistant-${index}`, parentUuid, sessionId, timestamp, message: { content: [{ type: "tool_use", id, name: index ? "Write" : "Bash", input: args }] } },
+ { type: "user", uuid: `result-${index}`, parentUuid: `assistant-${index}`, sessionId, timestamp, message: { content: [{ type: "tool_result", tool_use_id: id, content: `Historical result ${index}` }] } },
+ );
+ parentUuid = `result-${index}`;
+ }
+ }
+ if (source === "qoder") records.push({ type: "active-leaf", sessionId, leafUuid: parentUuid });
+ await writeFile(join(sourceDir, `${source}.jsonl`), records.map((record) => JSON.stringify(record)).join("\n") + "\n");
+ const service = new SessionImportService({ agentDir: join(root, "agent"), sessionRoot: join(root, "sessions") });
+ try {
+ const scan = await service.scan({ customRoot: sourceDir });
+ const candidate = scan.candidates.find((item) => item.source === source);
+ assert.ok(candidate, `Missing ${source} fixture`);
+ const preview = await service.preview(candidate.id);
+ let job = await service.start([{ candidateId: candidate.id, fingerprint: preview.fingerprint, cwd: root }]);
+ const deadline = Date.now() + 5000;
+ while (job.status === "running" && Date.now() < deadline) {
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ job = await service.getJob(job.id);
+ }
+ assert.equal(job.status, "completed");
+ assert.equal(job.results[0].status, "imported");
+ assert.equal(existsSync(join(root, "水果01.md")), false, "Import must not execute writes");
+ return SessionManager.open(job.results[0].path).getEntries();
+ } finally {
+ await service.close();
+ await rm(root, { recursive: true, force: true });
+ }
+}
+
test("singleton thinking has one disclosure; streaming growth preserves its mounted body and manual choice", { skip }, async () => {
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
const errors = [];
@@ -225,3 +284,46 @@ test("unopened singletons grow into folded groups; single-level layouts fit narr
await page.close();
}
});
+
+for (const source of ["codex", "qoder"]) {
+ test(`${source} imported tools are folded with truthful states and expandable details`, { skip }, async () => {
+ const entries = await importedToolEntries(source);
+ const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
+ try {
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await page.goto(url);
+ await page.waitForFunction(() => typeof window.importedFixture === "function");
+ await page.evaluate((items) => window.importedFixture(items), entries);
+ const group = page.locator(".process-tool-group");
+ const header = page.locator(".tool-group-trigger");
+ await header.waitFor({ state: "visible" });
+ assert.equal(await group.count(), 1);
+ assert.equal(await group.getAttribute("data-running"), "false");
+ assert.match(await header.innerText(), /工具调用 11 项|Recorded tool calls: 11/);
+ assert.doesNotMatch(await header.innerText(), /已完成|Completed/);
+ assert.equal(await header.getAttribute("aria-expanded"), "false");
+ const body = page.locator(".tool-group-disclosure");
+ assert.equal(await body.getAttribute("inert"), "");
+ assert.ok(await body.evaluate((element) => element.getBoundingClientRect().height < 1));
+ assert.equal(await page.locator('.tool-call[data-tool-state="unknown"]').count(), 11);
+ const screenshots = process.env.STEPCODE_WEB_QA_DIR || join(tmpdir(), "stepcode-imported-tool-group-qa");
+ await mkdir(screenshots, { recursive: true });
+ await page.screenshot({ path: join(screenshots, `${source}-folded.png`) });
+ await header.focus();
+ await page.keyboard.press("Enter");
+ assert.equal(await header.getAttribute("aria-expanded"), "true");
+ assert.equal(await body.getAttribute("inert"), null);
+ await page.locator(".tool-toggle").first().click();
+ await page.getByText("Historical result 0", { exact: true }).waitFor();
+ await page.screenshot({ path: join(screenshots, `${source}-expanded.png`) });
+ await header.click();
+ assert.equal(await header.getAttribute("aria-expanded"), "false");
+ assert.ok(await body.evaluate((element) => element.getBoundingClientRect().height < 1));
+ await header.click();
+ assert.equal(await page.locator(".tool-toggle").first().getAttribute("aria-expanded"), "true");
+ console.log(`${source} imported tool screenshots: ${screenshots}`);
+ } finally {
+ await page.close();
+ }
+ });
+}
diff --git a/apps/web/test/e2e/web.e2e.mjs b/apps/web/test/e2e/web.e2e.mjs
index 21ffb847..1dd91696 100644
--- a/apps/web/test/e2e/web.e2e.mjs
+++ b/apps/web/test/e2e/web.e2e.mjs
@@ -241,6 +241,22 @@ const until = async (probe, label, timeout = 8000) => {
await new Promise((resolve) => setTimeout(resolve, 25));
}
};
+const migrationScanCompleted = async (action, label) => {
+ const originalFetch = window.fetch;
+ let completed = 0;
+ window.fetch = async (input, init) => {
+ const path = new URL(typeof input === 'string' ? input : input.url, location.href).pathname;
+ const response = await originalFetch(input, init);
+ if (path === '/api/v1/session-imports/scan' && (init?.method ?? 'GET') === 'POST') completed++;
+ return response;
+ };
+ try {
+ await action();
+ await until(() => completed > 0 && $('#importScan') && !$('#importScan').disabled, label);
+ } finally {
+ window.fetch = originalFetch;
+ }
+};
const treeRows = () => $$("#fileTreeBody .file-row").map((row) => row.dataset.path);
const treeRow = (path) => $$("#fileTreeBody .file-row").find((row) => row.dataset.path === path);
const projects = () => $$("#projectGroups .project-group").map((group) => group.dataset.cwd);
@@ -251,6 +267,480 @@ const tabs = () => $$("#previewTabs .preview-tab").map((tab) => tab.textContent.
const ALT_META = 1 | 4
const SHIFT_META = 8 | 4
+test("session migration imports reviewed snapshots while sources continue writing", { skip }, async () => {
+ const codexRoot = join(base, "migration-codex")
+ const claudeRoot = join(base, "migration-claude", "projects")
+ await mkdir(join(codexRoot, "sessions"), { recursive: true })
+ await mkdir(join(codexRoot, "archived_sessions"), { recursive: true })
+ await mkdir(claudeRoot, { recursive: true })
+ const timestamp = "2026-10-22T00:00:00.000Z"
+ const sourcePath = join(codexRoot, "sessions", "rollout-migration.jsonl")
+ const input = [
+ { type: "session_meta", timestamp, payload: { id: "migration-codex", cwd: workspace, cli_version: "0.163.0-alpha.11", history_mode: "paginated" } },
+ { type: "response_item", timestamp, payload: { type: "message", role: "user", content: [{ type: "input_text", text: "Migration acceptance quokkabridge " }] } },
+ { type: "response_item", timestamp, payload: { type: "function_call", call_id: "migration-tool", name: "exec_command", arguments: '{"cmd":"never execute this historical call"}' } },
+ ].map(JSON.stringify).join("\n") + "\n"
+ await writeFile(sourcePath, input)
+ // An unreadable archive must not even produce a scan/parse failure by default.
+ await writeFile(join(codexRoot, "archived_sessions", "rollout-archive.jsonl"), "{")
+ await writeFile(join(claudeRoot, "migration-claude.jsonl"), [
+ { type: "user", uuid: "cc-user", parentUuid: null, sessionId: "migration-claude", cwd: workspace, timestamp, message: { content: "Claude migration acceptance " } },
+ { type: "assistant", uuid: "cc-assistant", parentUuid: "cc-user", sessionId: "migration-claude", timestamp, message: { content: [{ type: "text", text: "Historical CC reply" }] } },
+ ].map(JSON.stringify).join("\n") + "\n")
+ const scanned = await browser.run(`
+ if ($("#settingsView").hidden) $("#settingsButton").click();
+ await until(() => !$("#settingsView").hidden, "settings");
+ $("[data-settings-page='migration']").click();
+ await until(() => $("#importScan"), "migration page");
+ if ($('#importCustomToggle') || $('#importCustomPath')) throw new Error('Custom paths must be unavailable');
+ if ($('#importIncludeArchived')) throw new Error('Archive option must be absent');
+ const archived = false;
+ await until(() => $$(".session-import-row").length === 2, "two active source sessions");
+ await until(() => $$('[data-import-app] .session-import-logo').every(img => img.complete && img.naturalWidth > 0), 'platform logos loaded');
+ if (!$('[data-import-app="codex"] .session-import-logo').getAttribute('src').endsWith('/codex.svg')) throw new Error('Codex logo mismatch');
+ if (!$('[data-import-app="claude-code"] .session-import-logo').getAttribute('src').endsWith('/claudecode.svg')) throw new Error('Claude logo mismatch');
+ if ($$('[data-import-app]').some(group => group.open)) throw new Error('Applications must start collapsed');
+ if ($$('[data-import-project]').some(group => group.open)) throw new Error('Projects must start collapsed');
+ for (const group of $$('[data-import-app]')) group.querySelector('summary').click();
+ for (const group of $$('[data-import-project]')) group.querySelector('summary').click();
+ return { archived, errors: !!$(".session-import-errors"), sources: $$(".session-import-row").map(row => row.dataset.importSource).sort() };
+ `)
+ assert.deepEqual(scanned, { archived: false, errors: false, sources: ["claude-code", "codex"] })
+ const grouping = await browser.run(`
+ const groups = $$('[data-import-app]').map(node => node.dataset.importApp);
+ let codex = $('[data-import-app="codex"]');
+ const titles = $$('.session-import-section-head h1, .session-import-section-head h2').map(node => node.textContent);
+ const pathVisible = codex.textContent.includes(${JSON.stringify(sourcePath)});
+ codex.querySelector('summary').click();
+ const collapsed = !codex.open;
+ const search = $('.session-import-toolbar input[type="search"]');
+ search.value = 'rollout-migration.jsonl';
+ search.dispatchEvent(new Event('input'));
+ await until(() => $$('.session-import-row').length === 1, 'source path search');
+ codex = $('[data-import-app="codex"]');
+ const staysCollapsed = !codex.open;
+ codex.querySelector('summary').click();
+ const expanded = codex.open;
+ search.value = 'no-matching-migration-session';
+ search.dispatchEvent(new Event('input'));
+ await until(() => $$('.session-import-row').length === 0, 'empty search');
+ const emptyCards = $$('[data-import-app]').length;
+ const emptyHints = $$('.session-import-list .empty-list').length;
+ search.value = '';
+ search.dispatchEvent(new Event('input'));
+ await until(() => $$('.session-import-row').length === 2, 'cleared search');
+ return { apps: groups, titles, pathVisible, collapsed, staysCollapsed, expanded, emptyCards, emptyHints };
+ `)
+ assert.deepEqual(grouping.apps, ["codex", "claude-code"])
+ assert.equal(grouping.titles.length, 2)
+ assert.equal(grouping.pathVisible, false)
+ assert.equal(grouping.collapsed, true)
+ assert.equal(grouping.staysCollapsed, true)
+ assert.equal(grouping.expanded, true)
+ assert.equal(grouping.emptyCards, 0)
+ assert.equal(grouping.emptyHints, 0)
+ await browser.run(`
+ await until(() => $$('.session-import-report').length === 2 && !$('#importScan').disabled, 'automatic preview statistics');
+ const oldRow = $('.session-import-row');
+ $('#importScan').click();
+ await until(() => $('.session-import-row') !== oldRow && !$('#importScan').disabled, 'manual rescan completed');
+ if ($$('[data-import-app]').some(group => group.open)) throw new Error('Rescan must collapse cards');
+ for (const group of $$('[data-import-app]')) group.querySelector('summary').click();
+ for (const group of $$('[data-import-project]')) group.querySelector('summary').click();
+ for (const key of ['customRoot', 'codexRoot', 'claudeRoot', 'workbuddyRoot', 'qoderRoot']) {
+ const response = await fetch('/api/v1/session-imports/scan', { method:'POST', headers:{'content-type':'application/json'}, body:JSON.stringify({[key]: '/must-not-scan'}) });
+ if (response.status !== 400) throw new Error('Custom path override was accepted: ' + key);
+ }
+
+ if ($('.session-import-excerpt')) throw new Error('Preview body must not appear in list');
+ if ($('#importSelectAll')) throw new Error('Global select all must be absent');
+ const appOpen = $('[data-import-app="codex"]').open;
+ $('[data-import-select="codex"]').click();
+ await until(() => $('[data-import-source="codex"] input[type="checkbox"]').checked, 'app selected');
+ if ($('[data-import-select="codex"]').type !== 'checkbox' || $('[data-import-app="codex"]').open !== appOpen) throw new Error('Checkbox must not toggle application folding');
+ const selectedRow = $('[data-import-source="codex"]');
+ const unselectedRow = $('[data-import-source="claude-code"]');
+ await until(() => getComputedStyle(selectedRow).backgroundColor !== getComputedStyle(unselectedRow).backgroundColor, 'selected row highlight');
+ if (getComputedStyle($('.session-import-toolbar .search-input')).borderTopStyle !== 'solid') throw new Error('Search input has no border');
+ if ($('[data-import-source="claude-code"] input[type="checkbox"]').checked) throw new Error('App select leaked');
+ const projectSelect = $('[data-import-app="claude-code"] .session-import-select-group[data-import-select^="claude-code:"]');
+ const projectOpen = projectSelect.closest('details').open;
+ projectSelect.click();
+ await until(() => $('[data-import-source="claude-code"] input[type="checkbox"]').checked, 'project selected');
+ if (projectSelect.type !== 'checkbox' || projectSelect.closest('details').open !== projectOpen) throw new Error('Project checkbox must not toggle folding');
+ if ($('#importConfirm') || !$('[data-import-app="codex"] > summary [data-import-confirm="codex"]')) throw new Error('Import must be platform-scoped');
+ if ($('.session-import-row .session-import-path')) throw new Error('Session paths visible');
+ if ($('[data-import-project] summary .session-import-project-path')) throw new Error('Project path must be hidden');
+ if (!/已选.*1|1.*selected/i.test($('[data-import-app="codex"] > summary').textContent)) throw new Error('App selected count missing');
+ if (!/已选.*1|1.*selected/i.test($('[data-import-app="claude-code"] [data-import-project] > summary').textContent)) throw new Error('Project selected count missing');
+ if ($('[data-import-app="codex"] input:not([type="checkbox"])')) throw new Error('Scanned path must not be editable');
+ if ($('.session-import-root')) throw new Error('Source paths must be hidden');
+ await until(() => !$('[data-import-confirm="codex"]').disabled, "preview completion");
+ if ($('.session-import-target') || $('#importReviewSelected')) throw new Error('Obsolete target or review controls');
+ const button = $('[data-import-source="codex"] button');
+ button.click();
+ await until(() => $('.session-import-preview-popover'), 'floating preview');
+ await until(() => !button.disabled, 'preview check complete');
+ if (!$('.session-import-preview-popover').textContent.includes('Migration acceptance')) throw new Error('Missing preview content');
+ const panel = $('.session-import-preview-popover');
+ const rect = panel.getBoundingClientRect();
+ const top = document.elementFromPoint(Math.min(innerWidth - 1, rect.left + rect.width / 2), Math.min(innerHeight - 1, rect.top + 20));
+ if (!panel.contains(top)) throw new Error('Preview is covered by settings');
+ button.click();
+ await until(() => !$('.session-import-preview-popover'), 'closed preview');
+ return true;
+ `)
+ await browser.send("Emulation.setDeviceMetricsOverride", {
+ width: 390, height: 844, deviceScaleFactor: 1, mobile: false,
+ })
+ try {
+ const layout = await browser.run(`
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ const content = $(".settings-content");
+ const buttons = $$("[data-import-confirm]").map(node => node.getBoundingClientRect());
+ return {
+ contentWidth: content.getBoundingClientRect().width,
+ overflowing: content.scrollWidth > content.clientWidth,
+ offenders: [...content.querySelectorAll('*')].filter(node => node.getBoundingClientRect().right > innerWidth).slice(0, 12).map(node => ({class: node.className, width: node.getBoundingClientRect().width})),
+ buttonsFit: buttons.every(rect => rect.left >= 0 && rect.right <= innerWidth),
+ };
+ `)
+ assert.ok(layout.contentWidth >= 280, JSON.stringify(layout))
+ assert.equal(layout.overflowing, false, JSON.stringify(layout))
+ assert.equal(layout.buttonsFit, true)
+ } finally {
+ await browser.send("Emulation.setDeviceMetricsOverride", {
+ width: 1280, height: 900, deviceScaleFactor: 1, mobile: false,
+ })
+ }
+ const updatedInput = input + [
+ { type: "event_msg", timestamp, payload: { type: "token_count", info: {} } },
+ { type: "response_item", timestamp, payload: { type: "message", role: "user", content: [{ type: "input_text", text: "Unreviewed source append" }] } },
+ ].map(JSON.stringify).join("\n") + "\n"
+ await writeFile(sourcePath, updatedInput)
+ await browser.run(`
+ $('[data-import-confirm="codex"]').click();
+ await until(() => $$('.session-import-result').length === 1 && !$('[data-import-confirm="claude-code"]').disabled, 'Codex imported separately');
+ if ($('[data-import-source="codex"]') || $('[data-import-app="codex"]')) throw new Error('Imported Codex session or empty card still appears in source list');
+ if (!$('[data-import-source="claude-code"] input').checked) throw new Error('Other platform selection was cleared');
+ $('[data-import-confirm="claude-code"]').click();
+ await until(() => $$(".session-import-result").length === 2, "completed imports");
+ if ($('.session-import-row') || $('[data-import-app]')) throw new Error('Imported sessions must exist only in history, without empty source cards');
+ await migrationScanCompleted(() => $('#importScan').click(), 'rescan with imported history');
+ if ($('.session-import-row') || $('[data-import-app]') || $$('.session-import-result').length !== 2) throw new Error('Rescan restored imported source sessions or empty cards');
+ const jobs = await (await fetch("/api/v1/session-imports")).json();
+ if (jobs.jobs.length !== 2 || jobs.jobs.some(job => job.results.length !== 1 || job.results[0].status !== 'imported')) throw new Error('Jobs must be per-platform');
+ window.migrationJob = { results: jobs.jobs.flatMap(job => job.results) };
+ const history = $$('[data-import-history-app]');
+ if (history.length !== 2 || history.some(group => group.querySelectorAll('.session-import-result').length !== 1)) {
+ throw new Error('Import history is not grouped by app');
+ }
+ if ($('.session-import-result .session-import-path')) throw new Error('History session paths visible');
+ if (history[0].open) throw new Error('Import history must start collapsed');
+ for (const group of history) group.querySelector('summary').click();
+ for (const group of $$('[data-import-history-project]')) group.querySelector('summary').click();
+ return true;
+ `)
+ const imported = await browser.run(`
+ const job = window.migrationJob;
+ const sessions = (await (await fetch("/api/v1/sessions")).json()).sessions;
+ const codex = sessions.find(session => job.results.some(result => result.sessionId === session.id) && session.name.startsWith("[codex]"));
+ if (!codex) throw new Error("Codex import missing from native sessions");
+ const snapshot = await (await fetch("/api/v1/sessions/" + codex.id)).json();
+ if (!snapshot.entries.some(entry => entry.imported)) throw new Error("Missing source provenance");
+ if (JSON.stringify(snapshot.entries).includes("Unreviewed source append")) throw new Error("Imported unreviewed content");
+ const result = job.results.find(result => result.sessionId === codex.id);
+ // Grouped history renders rows per app, so select by the imported session id, not DOM order.
+ document.querySelector('.session-import-result[data-import-result="' + codex.id + '"] button').click();
+ await until(() => $("#settingsView").hidden, "opened native import");
+ await until(() => document.body.textContent.includes("Migration acceptance quokkabridge"), "imported history");
+ return { id: codex.id, hasUnknown: !!$("[data-tool-state='unknown']") };
+ `)
+ assert.equal(imported.hasUnknown, true)
+ assert.equal(await readFile(sourcePath, "utf8"), updatedInput)
+ await browser.send("Page.reload")
+ await browser.run(`await until(() => document.body.textContent.includes("Migration acceptance quokkabridge"), "reopened after refresh");`)
+ const search = await browser.run(`
+ const result = await (await fetch("/api/v1/search?q=quokkabridge")).json();
+ return result.hits.some(hit => hit.sessionId === ${JSON.stringify(imported.id)});
+ `)
+ assert.equal(search, true)
+ await browser.run(`
+ await migrationScanCompleted(async () => {
+ $('#settingsButton').click();
+ $('[data-settings-page="general"]').click();
+ await until(() => !$('.session-import-page'), 'general page before migration reopen');
+ $('[data-settings-page="migration"]').click();
+ }, 'migration reopened with persisted history');
+ await until(() => $$('.session-import-result').length === 2, 'persisted history rows');
+ if ($('.session-import-row') || $('[data-import-app]')) throw new Error('Reopening migration restored imported sessions or empty cards');
+ $('#settingsBack').click();
+ `)
+})
+
+test("session migration failures stay silent and preserve truthful job state", { skip }, async () => {
+ await browser.run(`
+ await until(() => $('#settingsButton') && $('#authBanner').hidden, 'authenticated page');
+ if ($('#settingsView').hidden) $('#settingsButton').click();
+ $('[data-settings-page="general"]').click();
+ await until(() => !$('.session-import-page'), 'previous migration unmounted');
+ const originalFetch = window.fetch;
+ const path = '/api/v1/session-imports';
+ const diagnostic = '__migration_diagnostic__';
+ const expiredId = 'migration-fixture-expired-session';
+ const createdAt = '2026-10-22T00:00:00.000Z';
+ const candidate = (id, source, title) => ({
+ id, source, title, sessionId: id, cwd: ${JSON.stringify(workspace)},
+ modified: createdAt, archived: false, version: 'fixture',
+ sourcePath: ${JSON.stringify(join(base, "mock-imports"))} + '/' + id + '.jsonl',
+ });
+ const healthy = candidate('healthy', 'codex', 'Healthy migration fixture');
+ const broken = candidate('broken', 'qoder', 'Broken migration fixture');
+ const saved = { id: 'saved', createdAt, status: 'completed', total: 1, results: [{
+ candidateId: 'saved-candidate', source: 'claude-code', title: 'Saved migration fixture',
+ status: 'imported', sessionId: expiredId,
+ }] };
+ let history = [saved];
+ let scanFailure = false;
+ let historyFailure = false;
+ let sourceErrors = true;
+ let brokenPreview = true;
+ let healthyPreviewFailure = false;
+ let startFailure = true;
+ let pollFailure = true;
+ let running;
+ const calls = { history: 0, scan: 0, previews: [], start: [], cancel: 0, poll: 0, open: 0 };
+ const leaks = new Set();
+ const inspect = (node) => {
+ if ((node.textContent ?? '').includes(diagnostic)) leaks.add('technical diagnostic entered DOM');
+ };
+ const observer = new MutationObserver(records => {
+ inspect(document.body);
+ for (const record of records) {
+ inspect(record.target);
+ for (const node of record.addedNodes) inspect(node);
+ }
+ });
+ observer.observe(document.body, { subtree: true, childList: true, characterData: true });
+ const response = (body, status = 200) => new Response(JSON.stringify(body), {
+ status, headers: { 'content-type': 'application/json' },
+ });
+ const failure = stage => response({ error: diagnostic + stage }, 500);
+ window.fetch = async (input, init) => {
+ const url = new URL(typeof input === 'string' ? input : input.url, location.href);
+ const method = init?.method ?? (input instanceof Request ? input.method : 'GET');
+ if (url.origin !== location.origin) return originalFetch(input, init);
+ if (url.pathname === '/api/v1/sessions/' + expiredId && method === 'GET') {
+ calls.open++;
+ return failure('open');
+ }
+ if (url.pathname !== path && !url.pathname.startsWith(path + '/')) return originalFetch(input, init);
+ if (url.pathname === path && method === 'GET') {
+ calls.history++;
+ return historyFailure ? failure('history') : response({ jobs: history });
+ }
+ if (url.pathname === path + '/scan' && method === 'POST') {
+ calls.scan++;
+ return scanFailure ? failure('scan') : response({
+ roots: { codex: '/fixture/codex', qoder: '/fixture/qoder', 'claude-code': '/fixture/claude' },
+ candidates: [healthy, broken],
+ errors: sourceErrors ? [{ source: 'claude-code', message: diagnostic + 'source' }] : [],
+ });
+ }
+ if (url.pathname === path + '/preview' && method === 'POST') {
+ const { candidateId } = JSON.parse(init.body);
+ calls.previews.push(candidateId);
+ if ((candidateId === broken.id && brokenPreview) || (candidateId === healthy.id && healthyPreviewFailure)) return failure('preview');
+ const item = candidateId === healthy.id ? healthy : broken;
+ return response({ ...item, fingerprint: 'fixture-' + item.id, messageCount: 2, toolCount: 1,
+ warnings: [{ code: 'unsupported_content', count: 73519 }], excerpt: item.title + ' preview',
+ });
+ }
+ if (url.pathname === path && method === 'POST') {
+ calls.start.push(JSON.parse(init.body));
+ if (startFailure) return failure('start');
+ running = { id: 'fixture-running', createdAt, status: 'running', total: 1, results: [] };
+ history = [running, ...history];
+ return response(running);
+ }
+ if (url.pathname === path + '/fixture-running' && method === 'DELETE') {
+ calls.cancel++;
+ return failure('cancel');
+ }
+ if (url.pathname === path + '/fixture-running' && method === 'GET') {
+ calls.poll++;
+ return pollFailure ? failure('poll') : response(running);
+ }
+ throw new Error('Unexpected migration fixture request: ' + method + ' ' + url.pathname);
+ };
+ try {
+ const painted = async () => {
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
+ };
+ const silent = async stage => {
+ await painted();
+ inspect(document.body);
+ if (leaks.size) throw new Error(stage + ': ' + [...leaks].join(', '));
+ if ($('.session-import-error, .session-import-errors, .session-import-row-failure, .session-import-list .empty-list')) throw new Error(stage + ': diagnostic or empty-result UI remains');
+ if (!$('#errorToast').hidden) throw new Error(stage + ': unexpected error toast');
+ if ($$('.session-import-report').some(report => report.querySelectorAll('span').length !== 1)) throw new Error(stage + ': preview compatibility warnings remain');
+ };
+ const expand = () => {
+ for (const group of $$('[data-import-app], [data-import-project], [data-import-history-app], [data-import-history-project]')) group.open = true;
+ };
+ const ready = async label => {
+ await until(() => $('#importScan') && !$('#importScan').disabled, label);
+ await painted();
+ };
+ const rescan = async label => {
+ const previous = calls.history;
+ $('#importScan').click();
+ await until(() => calls.history > previous, label + ' request');
+ await ready(label);
+ };
+ const savedRemains = () => {
+ if (!$('[data-import-result="' + expiredId + '"]')) throw new Error('Successful saved history disappeared');
+ };
+ const healthyOnly = () => {
+ const rows = $$('.session-import-row');
+ if (rows.length !== 1 || rows[0].querySelector('strong').textContent !== healthy.title) throw new Error('Only the healthy candidate should be displayed');
+ if ($('[data-import-app="qoder"], [data-import-app="claude-code"]')) throw new Error('Failed or empty source card remains');
+ if (rows[0].querySelector('input').disabled) throw new Error('Healthy candidate is not selectable');
+ };
+ $('[data-settings-page="migration"]').click();
+ await until(() => calls.scan === 1 && calls.previews.length === 2, 'injected scan and previews');
+ await ready('initial scan complete');
+ await silent('per-source scan errors and partial preview failure');
+ healthyOnly();
+ savedRemains();
+ expand();
+ const search = $('.session-import-toolbar input[type="search"]');
+ search.value = 'no-matching-fixture';
+ search.dispatchEvent(new Event('input'));
+ await until(() => !$('.session-import-row'), 'no search matches');
+ if ($('[data-import-app]')) throw new Error('Empty search left an application card');
+ await silent('empty search');
+ savedRemains();
+ search.value = '';
+ search.dispatchEvent(new Event('input'));
+ await until(() => $('.session-import-row'), 'healthy search restored');
+ healthyOnly();
+ sourceErrors = false;
+ brokenPreview = false;
+ await rescan('successful rescan recovery');
+ if ($$('.session-import-row').length !== 2) throw new Error('Rescan did not restore recovered candidate');
+ await silent('recovered preview');
+ expand();
+ const row = $$('.session-import-row').find(node => node.querySelector('strong').textContent === healthy.title);
+ row.querySelector('input').click();
+ row.querySelector('button').click();
+ await until(() => $('.session-import-preview-popover') && !row.querySelector('button').disabled, 'healthy preview opened');
+ row.querySelector('button').click();
+ await until(() => !$('.session-import-preview-popover'), 'healthy preview closed');
+ healthyPreviewFailure = true;
+ row.querySelector('button').click();
+ await until(() => !$('#importScan').disabled && !row.isConnected, 'previously selected preview removed after failure');
+ if ($('.session-import-preview-popover')) throw new Error('Failed preview left stale content open');
+ if ($('[data-import-confirm="codex"]')) throw new Error('Failed selected candidate can still be submitted');
+ await silent('selected preview failure');
+ healthyPreviewFailure = false;
+ await rescan('restore selected preview');
+ saved.results[0].warning = diagnostic + 'saved-warning';
+ const failedHistory = { id: 'persisted-failed', createdAt, status: 'completed', total: 1, results: [{
+ candidateId: 'persisted-failed-candidate', source: 'qoder', title: 'Failed saved fixture',
+ status: 'failed', error: diagnostic + 'result-error', warning: diagnostic + 'result-warning',
+ }] };
+ history = [saved, failedHistory];
+ await rescan('persistent result diagnostics');
+ const failedStatus = () => {
+ const result = $('[data-import-result="persisted-failed-candidate"]');
+ if (!result || !/未导入|Not imported/i.test(result.textContent)) throw new Error('Real failed history must remain as Not imported');
+ if (result.querySelector('button')) throw new Error('Failed history must not offer a successful session');
+ };
+ failedStatus();
+ await silent('persistent result error and warning');
+ scanFailure = true;
+ await rescan('whole scan failure');
+ if ($('.session-import-row, [data-import-app]')) throw new Error('Failed scan retained source candidates or empty cards');
+ savedRemains();
+ failedStatus();
+ await silent('whole scan failure');
+ scanFailure = false;
+ await rescan('scan recovery');
+ if ($$('.session-import-row').length !== 2) throw new Error('Healthy candidates did not recover after scan failure');
+ historyFailure = true;
+ const scansBeforeHistoryFailure = calls.scan;
+ await rescan('history GET failure');
+ if (calls.scan !== scansBeforeHistoryFailure) throw new Error('Scan continued after failed history read');
+ if ($('.session-import-row, [data-import-app]')) throw new Error('Failed history read retained candidates');
+ savedRemains();
+ failedStatus();
+ await silent('history GET failure');
+ historyFailure = false;
+ await rescan('history recovery');
+ if ($$('.session-import-row').length !== 2) throw new Error('History recovery lost healthy candidates');
+ expand();
+ $('[data-import-select="codex"]').click();
+ await until(() => !$('[data-import-confirm="codex"]').disabled, 'healthy selection ready');
+ const resultsBeforeStart = $$('.session-import-result').length;
+ $('[data-import-confirm="codex"]').click();
+ await until(() => calls.start.length === 1 && !$('[data-import-confirm="codex"]').disabled, 'start POST failure settled');
+ if ($$('.session-import-result').length !== resultsBeforeStart) throw new Error('Failed submission fabricated an import result');
+ if (!$('[data-import-source="codex"] input').checked) throw new Error('Failed submission discarded retryable selection');
+ if ($('.session-import-history > header button')) throw new Error('Failed submission fabricated a running job');
+ if (calls.start[0].selections.length !== 1 || calls.start[0].selections[0].candidateId !== healthy.id) throw new Error('Submission included an unselected or failed candidate');
+ await silent('start POST failure');
+ startFailure = false;
+ $('[data-import-confirm="codex"]').click();
+ await until(() => calls.start.length === 2 && calls.poll >= 2, 'running job and failed poll retry');
+ const stillRunning = () => {
+ if (!$('#importScan').disabled || !$('.session-import-history > header button')) throw new Error('Uncertain running job became completed or cancelled');
+ if (!/正在导入|Importing/i.test($('.session-import-history > .settings-page-desc').textContent)) throw new Error('Last known running status was lost');
+ if ($$('.session-import-result').length !== resultsBeforeStart) throw new Error('Uncertain running job fabricated a result');
+ savedRemains();
+ failedStatus();
+ };
+ stillRunning();
+ await silent('poll GET failure');
+ const pollsBeforeCancel = calls.poll;
+ $('.session-import-history > header button').click();
+ await until(() => calls.cancel === 1 && calls.poll > pollsBeforeCancel, 'cancel DELETE failure and subsequent poll');
+ stillRunning();
+ if (calls.start.length !== 2 || calls.cancel !== 1) throw new Error('Failed mutation was automatically retried');
+ await silent('cancel DELETE failure');
+ running = { ...running, status: 'completed', results: [{
+ candidateId: healthy.id, source: healthy.source, title: healthy.title, cwd: healthy.cwd,
+ status: 'failed', error: diagnostic + 'job-result-error', warning: diagnostic + 'job-result-warning',
+ }] };
+ history = [running, ...history.filter(job => job.id !== running.id)];
+ pollFailure = false;
+ await until(() => !$('#importScan').disabled && $('[data-import-result="healthy"]'), 'poll recovery with real failed result');
+ const failedImport = $('[data-import-result="healthy"]');
+ if (!/未导入|Not imported/i.test(failedImport.textContent) || failedImport.querySelector('button')) throw new Error('Failed job result masquerades as an imported session');
+ if ($('.session-import-history > header button')) throw new Error('Completed job still offers cancellation');
+ if (!$('[data-import-source="codex"] input').checked) throw new Error('Failed result lost retryable selection');
+ if (calls.start.length !== 2 || calls.cancel !== 1) throw new Error('Mutation retried during poll recovery');
+ await silent('poll recovery and real failed result');
+ await rescan('rescan after failed import');
+ if ($$('.session-import-row').length !== 2) throw new Error('Failed import incorrectly hid healthy candidate as imported');
+ savedRemains();
+ expand();
+ $('[data-import-result="' + expiredId + '"] button').click();
+ await until(() => calls.open === 1 && $('#settingsView').hidden, 'failed history session open');
+ await silent('invalid imported session open');
+ } finally {
+ try {
+ $('[data-settings-page="general"]').click();
+ await until(() => !$('.session-import-page'), 'migration fixture unmounted');
+ } finally {
+ observer.disconnect();
+ window.fetch = originalFetch;
+ if (!$('#settingsView').hidden) $('#settingsBack').click();
+ }
+ }
+ `)
+})
+
let base
let workspace
let extra
@@ -374,6 +864,10 @@ before(async () => {
cwd: webRoot,
env: {
...process.env,
+ HOME: join(base, "scan-home"),
+ USERPROFILE: join(base, "scan-home"),
+ CODEX_HOME: join(base, "migration-codex"),
+ CLAUDE_CONFIG_DIR: join(base, "migration-claude"),
TSX_TSCONFIG_PATH: join(webRoot, "..", "..", "tsconfig.json"),
STEPCODE_WEB_PORT: String(port),
STEPCODE_WEB_CWD: workspace,
@@ -3517,3 +4011,119 @@ test("forking from the middle of a conversation branches there and leaves the pa
const after = await readFile(join(base, "agent", "sessions", "--e2e-fork-here--", sessionId + ".jsonl"), "utf8")
assert.ok(after.startsWith(before), "the parent's transcript kept every original record")
})
+
+test("WorkBuddy and Qoder sessions are identified and imported through the migration UI", { skip }, async () => {
+ const root = join(base, "scan-home")
+ const workbuddyRoot = join(root, ".workbuddy", "projects")
+ const qoderRoot = join(root, ".qoder", "projects")
+ await mkdir(workbuddyRoot, { recursive: true })
+ await mkdir(qoderRoot, { recursive: true })
+ const timestamp = "2026-10-26T00:00:00.000Z"
+ await writeFile(join(workbuddyRoot, "workbuddy.jsonl"), [
+ { type: "session-meta", sessionId: "wb-ui" },
+ { type: "message", id: "wb-u", sessionId: "wb-ui", cwd: workspace, role: "user", timestamp, content: [{ type: "input_text", text: "WorkBuddy fixture " }] },
+ { type: "ai-title", sessionId: "wb-ui", aiTitle: "WorkBuddy fixture" },
+ ].map(JSON.stringify).join("\n") + "\n")
+ await writeFile(join(qoderRoot, "qoder.jsonl"), [
+ { type: "workspace-directories", sessionId: "q-ui" },
+ { type: "user", sessionId: "q-ui", cwd: workspace, uuid: "q-u", parentUuid: null, timestamp, message: { role: "user", content: "Qoder fixture " } },
+ { type: "active-leaf", sessionId: "q-ui", leafUuid: "q-u" },
+ ].map(JSON.stringify).join("\n") + "\n")
+ const imported = await browser.run(`
+ if ($('#settingsView').hidden) $('#settingsButton').click();
+ $('[data-settings-page="migration"]').click();
+ await until(() => $('#importScan'), 'migration page ready');
+ if ($('#importCustomToggle') || $('#importCustomPath')) throw new Error('Custom path controls must be absent');
+ await until(() => $('[data-import-app="workbuddy"] .session-import-report') && $('[data-import-app="qoder"] .session-import-report') && !$('#importScan').disabled, 'new source previews');
+ await until(() => $$('[data-import-app] .session-import-logo').every(img => img.complete && img.naturalWidth > 0), 'new source logos');
+ if (!$('[data-import-app="workbuddy"] .session-import-logo').getAttribute('src').endsWith('/workbuddy.svg')) throw new Error('WorkBuddy logo mismatch');
+ if (!$('[data-import-app="qoder"] .session-import-logo').getAttribute('src').endsWith('/qoder-dark.svg')) throw new Error('Qoder logo mismatch');
+ if ($$('[data-import-app]').some(group => group.open)) throw new Error('New source cards must start collapsed');
+ for (const group of $$('[data-import-app]')) group.querySelector('summary').click();
+ for (const group of $$('[data-import-project]')) group.querySelector('summary').click();
+ if (!$$('.session-import-row strong').some(node => node.textContent.includes('WorkBuddy fixture'))) throw new Error('Missing WorkBuddy title');
+ $('[data-import-select="workbuddy"]').click();
+ $('[data-import-select="qoder"]').click();
+ await until(() => !$('[data-import-confirm="workbuddy"]').disabled, 'new source confirmation');
+ $('[data-import-confirm="workbuddy"]').click();
+ await until(() => $('[data-import-history-app="workbuddy"] .session-import-result') && !$('[data-import-confirm="qoder"]').disabled, 'WorkBuddy imported');
+ if ($('[data-import-source="workbuddy"]') || $('[data-import-app="workbuddy"]')) throw new Error('Imported WorkBuddy session or empty card still appears in source list');
+ if (!$('[data-import-source="qoder"] input').checked) throw new Error('Qoder selection must remain');
+ $('[data-import-confirm="qoder"]').click();
+ await until(() => $('[data-import-history-app="workbuddy"] .session-import-result') && $('[data-import-history-app="qoder"] .session-import-result'), 'new source import history');
+ if (['workbuddy', 'qoder'].some(source => $('[data-import-source="' + source + '"]') || $('[data-import-app="' + source + '"]'))) throw new Error('New source imports must exist only in history, without empty cards');
+ const jobs = (await (await fetch('/api/v1/session-imports')).json()).jobs;
+ const results = jobs.flatMap(job => job.results).filter(result => ['workbuddy', 'qoder'].includes(result.source));
+ for (const result of results) {
+ const snapshot = await (await fetch('/api/v1/sessions/' + result.sessionId)).json();
+ if (JSON.stringify(snapshot.entries).includes('')) throw new Error('User wrapper leaked into imported entries');
+ }
+ const workbuddy = results.find(result => result.source === 'workbuddy');
+ document.querySelector('.session-import-result[data-import-result="' + workbuddy.sessionId + '"] button').click();
+ await until(() => $('#settingsView').hidden && $('.message.user')?.dataset.content === 'WorkBuddy fixture', 'clean imported user message');
+ return results.map(result => ({ source: result.source, status: result.status }));
+ `)
+ assert.deepEqual(imported.sort((a, b) => a.source.localeCompare(b.source)), [{ source: "qoder", status: "imported" }, { source: "workbuddy", status: "imported" }])
+})
+
+test("new conversation offers a lightweight shortcut to session migration", { skip }, async () => {
+ await browser.send("Emulation.setEmulatedMedia", { features: [{ name: "prefers-reduced-motion", value: "reduce" }] })
+ const report = await browser.run(`
+ if (!$("#settingsView").hidden) $("#settingsBack").click();
+ await until(() => $("#newSession"), "new conversation");
+ $("#newSession").click();
+ const link = await until(() => $("#emptySessionImport"), "empty import shortcut");
+ return { text: link.textContent.trim(), title: $("#stream .empty-title")?.textContent, mark: !!$("#stream .empty-mark .icon") };
+ `)
+ assert.equal(report.text, "从其他AI应用导入会话")
+ assert.equal(report.title, "今天想推进什么?")
+ assert.equal(report.mark, true)
+ const qaDir = process.env.STEPCODE_WEB_QA_DIR
+ if (qaDir) await mkdir(qaDir, { recursive: true })
+ try {
+ for (const width of [1280, 390]) {
+ await browser.send("Emulation.setDeviceMetricsOverride", { width, height: 900, deviceScaleFactor: 1, mobile: false })
+ if (width === 390) await browser.run(`
+ $('#sidebarToggle').click();
+ await until(() => $('.app').classList.contains('sidebar-hidden'), 'sidebar collapsed for narrow layout');
+ `)
+ const layout = await browser.run(`
+ await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
+ const mark = $('#stream .empty-mark').getBoundingClientRect();
+ const title = $('#stream .empty-title').getBoundingClientRect();
+ const column = $('#stream .stream-inner').getBoundingClientRect();
+ const button = $('#emptySessionImport').getBoundingClientRect();
+ const centre = column.left + column.width / 2;
+ return {
+ headingCentreDelta: Math.abs((mark.left + title.right) / 2 - centre),
+ buttonCentreDelta: Math.abs(button.left + button.width / 2 - centre),
+ belowHeading: button.top >= Math.max(mark.bottom, title.bottom) + 8,
+ buttonFits: button.left >= column.left && button.right <= column.right,
+ hasSubtitle: !!$('#emptySessionImport small'),
+ horizontalOverflow: $('#stream').scrollWidth > $('#stream').clientWidth
+ };
+ `)
+ assert.ok(layout.headingCentreDelta < 2, JSON.stringify({ width, ...layout }))
+ assert.ok(layout.buttonCentreDelta < 2, JSON.stringify({ width, ...layout }))
+ assert.equal(layout.belowHeading, true, JSON.stringify({ width, ...layout }))
+ assert.equal(layout.buttonFits, true, JSON.stringify({ width, ...layout }))
+ assert.equal(layout.hasSubtitle, false)
+ assert.equal(layout.horizontalOverflow, false, JSON.stringify({ width, ...layout }))
+ if (qaDir) {
+ const shot = await browser.send("Page.captureScreenshot", { format: "png" })
+ await writeFile(join(qaDir, `empty-migration-${width}.png`), Buffer.from(shot.data, "base64"))
+ }
+ }
+ await browser.run(`
+ $('#emptySessionImport').click();
+ await until(() => !$('#settingsView').hidden && $('[data-page="migration"]'), 'migration settings');
+ if (!$('#importScan')) throw new Error('Migration page did not open');
+ $('#settingsBack').click();
+ `)
+ } finally {
+ await browser.send("Emulation.setDeviceMetricsOverride", { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false })
+ await browser.run(`if ($('.app').classList.contains('sidebar-hidden')) $('#sidebarShow').click();`)
+ await browser.send("Emulation.setEmulatedMedia", { features: [] })
+ }
+ if (qaDir) console.log(`Empty migration layout screenshots: ${qaDir}`)
+})
diff --git a/apps/web/test/turn-changes.test.ts b/apps/web/test/turn-changes.test.ts
index 8fa859a8..b1ece4f6 100644
--- a/apps/web/test/turn-changes.test.ts
+++ b/apps/web/test/turn-changes.test.ts
@@ -645,6 +645,29 @@ test("turn versions replay from a recorded write, whatever happened to the file
});
});
+test("imported file writes cannot replace native delivery versions", async () => {
+ await withRoot(async (root) => {
+ const s = session(root);
+ ask(s, "native-write");
+ await stepWrite(s, "native-call", "index.html", "native workspace
\n");
+ const historical = [
+ entry("imported-user", { role: "user", content: "Historical task" }),
+ entry("imported-call", {
+ role: "assistant",
+ content: [{ type: "toolCall", id: "historical-write", name: "write_file", arguments: { path: "index.html", content: "historical evidence
\n" } }],
+ }),
+ entry("imported-result", { role: "toolResult", toolCallId: "historical-write", isError: false }),
+ ].map((item) => ({ ...item, imported: { source: "workbuddy" as const, sessionId: "foreign", itemId: item.id } }));
+ s.entries.push(...historical);
+ ask(s, "native-reference");
+ assert.deepEqual(turnChanges(s.entries, "imported-user"), { changes: [], skipped: [] });
+ assert.deepEqual(await fileAtTurnEnd(s.entries, "native-reference", "index.html", root), { exact: true });
+ assert.equal(await content(join(root, "index.html")), "native workspace
\n");
+ await unlink(join(root, "index.html"));
+ assert.deepEqual(await fileAtTurnEnd(s.entries, "native-reference", "index.html", root), { text: "native workspace
\n", exact: true });
+ });
+});
+
test("a forward patch applies the recorded change and refuses content that moved on", () => {
assert.equal(applyPatch("a\nb\nc\n", PATCHES.edit), "a\nB\nc\nd\n");
assert.equal(applyPatch("a\nb", PATCHES.appendNoEol), "a\nb\nc");
diff --git a/packages/coding-agent/AGENTS.md b/packages/coding-agent/AGENTS.md
index 6f9728e0..894a2f75 100644
--- a/packages/coding-agent/AGENTS.md
+++ b/packages/coding-agent/AGENTS.md
@@ -35,6 +35,13 @@ Inherits the [root rules](../../AGENTS.md). This package is `@step-harness/codin
Core provides mechanisms that product features compose. Do not put a feature's state machine into tool loading, session storage, or the generic Agent Loop.
+`src/core/session-import/` owns offline foreign-session discovery, adapters and
+atomic import jobs. Keep source reads separate from live session opening. Preserve
+import provenance, exclude historical usage/model selection, and project source
+tools as evidence rather than executable calls. See `docs/session-import.md`.
+Keep explicit failed/unknown tool outcomes and supported compaction attachments
+in continuation context; discovery must not truncate a metadata-bearing JSONL record.
+
### src/features: concrete features and orchestration
| Feature | Main locations | Contents |
diff --git a/packages/coding-agent/docs/session-format.md b/packages/coding-agent/docs/session-format.md
index 144fb34f..8535df81 100644
--- a/packages/coding-agent/docs/session-format.md
+++ b/packages/coding-agent/docs/session-format.md
@@ -2,6 +2,14 @@
Sessions are stored as JSONL (JSON Lines) files. Each line is a JSON object with a `type` field. Session entries form a tree structure via `id`/`parentId` fields, enabling in-place branching without creating new files.
+Imported history may carry optional `imported` provenance on entries. It is
+excluded from native usage totals and source model selection; historical tools
+are projected as text evidence with explicit completed, failed or unknown
+outcomes for new requests. Imported compaction attachments use provenance-marked
+`custom_message` entries (`stepcode.imported-compaction-attachments`) so images
+and captions survive context projection without adding source user turns.
+See [offline session import](session-import.md).
+
## File Location
```
diff --git a/packages/coding-agent/docs/session-import.md b/packages/coding-agent/docs/session-import.md
new file mode 100644
index 00000000..3cacabe8
--- /dev/null
+++ b/packages/coding-agent/docs/session-import.md
@@ -0,0 +1,164 @@
+# Offline Session Import
+
+`SessionImportService` discovers and imports local Codex, Claude Code, WorkBuddy and Qoder history
+without starting either source agent or invoking a model. The Web settings page
+uses this same service. This is history migration, not restoration of source
+processes, approvals, credentials, filesystem checkpoints or hidden reasoning.
+
+## Lifecycle
+
+Construct the service with an explicit `agentDir`, optional `sessionRoot`, and
+an optional `onPersisted` index notification callback. Hosts must call `close()`
+and await it before closing their index.
+
+1. `scan({ customRoot?, codexRoot?, claudeRoot?, workbuddyRoot?, qoderRoot?, includeArchived? })` returns opaque
+ candidate IDs and per-source errors. Default roots honor `CODEX_HOME` and
+ `CLAUDE_CONFIG_DIR`; otherwise they are `~/.codex` and
+ `~/.claude/projects`. Codex archives and child sessions are excluded by
+ default. Claude has no universally recognized archive flag; only explicit
+ supported metadata is interpreted, never inactivity.
+2. `preview(candidateId)` reads a stable snapshot and returns its fingerprint,
+ bounded text excerpt, counts and compatibility warnings. It retains an
+ immutable, in-memory copy of the reviewed messages and embedded attachments.
+ It does not create or open a target session.
+3. `start([{ candidateId, fingerprint, cwd }])` accepts at most 100 selections
+ and returns a job. `cwd` must resolve to an existing absolute directory.
+4. `getJob(id)` and `listJobs()` return durable, per-session outcomes.
+ `cancel(id)` stops remaining work between sessions; poll for settlement.
+
+An automatic scan replaces prior candidate capabilities. A `customRoot` scan appends
+new candidates, deduplicating the same source and file path while preserving existing
+candidate IDs and reviewed snapshots. It returns newly discovered candidates only, with `existingCount` reporting matched
+sessions already in the list so a duplicate-only scan is not mistaken for no matches.
+Submitted jobs retain their own
+reviewed snapshots and selection copies. Import uses exactly the reviewed
+snapshot, even if the source continues writing or its attachments change.
+Preview again to include later source changes. Unsubmitted previews expire after
+15 minutes or host restart and can be evicted under cache pressure. The cache
+holds at most 256 MiB of serialized UTF-16 data; submitted snapshots are pinned
+until their jobs settle. Expired or evicted previews require another preview,
+never an implicit re-read at import time.
+An identical source ID and snapshot generates the same target ID, so
+reimport never overwrites an existing target, including one continued in StepCode.
+A changed snapshot generates a new copy. Target publication uses an exclusive
+temporary file and an atomic, non-overwriting hard link on the same filesystem.
+Filesystems without hard-link support fail visibly. Completed imports remain
+when a batch is cancelled. A restarted host marks unfinished journals interrupted;
+re-scanning and retrying safely skips already published snapshots.
+Result titles and selected destination `cwd` survive host restart. Every
+per-selection imported, skipped or failed result includes the selected absolute
+`cwd`, even when that directory could not be opened; it is not the source project
+or a claim that a skipped existing session has that cwd. A batch-level journal
+failure may have no destination. Persisted cwd values must be absolute strings
+without NUL characters; invalid values reject the journal.
+
+Older journals can recover missing cwd and title from at most the first 1 MiB of
+a native session, only when its resolved path is inside `sessionRoot/imported`.
+Recovery uses the native session header for cwd, `session_info` name or first user
+text for title, and validated entry provenance for source. Existing metadata is
+preserved. Missing, inaccessible, escaped or out-of-window metadata remains
+unknown; recovery neither opens a live session nor rewrites native history.
+If the index callback fails after publication, the result remains imported (or
+skipped), with a warning rather than a false failure; retrying also re-notifies
+the index.
+
+## Desktop scanning
+
+Opening the migration page automatically scans the supported applications' default
+projects directories. The rescan action repeats that scan; application and project
+cards start collapsed. The desktop HTTP scan endpoint accepts only `includeArchived`
+and rejects arbitrary source-directory overrides. There is no custom path entry in
+the desktop UI. The SDK directory options above remain available for trusted
+programmatic callers and isolated tests; they are not desktop input fields.
+
+The desktop list contains only unimported candidates with a successful preview
+that match the search text. Missing sources, failed discovery or previews, and
+searches with no matches have no row or error hint. Technical errors and compatibility
+warnings remain available through the SDK and persisted import metadata, but
+are not rendered as desktop diagnostics. Failed imports retain their actual
+status and appear as "Not imported"; presentation never changes a failure into
+success. Polling failures retain the last known status, and failed mutations
+are not retried automatically. Unavailable imported-session links suppress
+technical toast messages; global authentication-expiry handling is unchanged.
+
+## WorkBuddy and Qoder
+
+Default roots are `~/.workbuddy/projects` and `~/.qoder/projects`. A custom root
+can contain a mixture of supported formats; Qoder-specific header records prevent
+its Claude-shaped messages from being misidentified as Claude Code. WorkBuddy
+session status files under `sessions/` are not transcripts.
+
+The adapters were verified against local JSONL structures on macOS. WorkBuddy
+message blocks and independent tool calls/results are migrated with source IDs;
+completed, failed and unknown tool outcomes remain distinct. Unsupported opaque
+reasoning produces a warning rather than being guessed or decrypted. Qoder uses
+the final `active-leaf` parent chain rather than the most recently stored branch.
+Its runtime configuration and attachment/control envelopes are not activated;
+unmigratable attachment records produce compatibility warnings. Other application
+versions, encrypted/cloud-only history and restoration of live processes are not
+guaranteed. Qcoder was interpreted as the locally installed Qoder product.
+
+## Compatibility
+
+The adapters support Codex response-item JSONL, including paginated-mode rollout
+records, independent function calls/results, source compaction summaries and
+rollback events; and Claude message chains, content blocks and tool-result
+envelopes. Tool names are retained, not renamed to apparently equivalent native
+tools. Unknown Codex record formats fail rather than silently declare success.
+Unsupported Codex item types remain quoted historical text and produce warnings.
+Claude non-message metadata is not replayed. Its source compaction boundaries
+are not reconstructed; only messages reachable from the selected parent chain
+are migrated. Real Claude installations and versions outside the fixtures still
+require acceptance testing; this is not a lossless source-state backup.
+
+Imported entries carry an optional `imported` provenance object. Native context
+projection converts historical tool calls/results to text evidence; it does not
+restore their execution protocol. Tool results explicitly carry completed,
+failed or unknown status into new requests; absent source outcome information
+is never interpreted as success. Imported assistant messages do not select the
+new session's model. Usage ledgers exclude imported entries; source numeric usage,
+when available, remains provenance, not newly incurred StepCode usage. Internal
+zero usage required by the native assistant wire type is not a source measurement.
+
+The desktop groups consecutive ordinary historical tool calls into its existing
+folded process rows, including calls whose outcomes are unknown. Groups with
+unconfirmed outcomes show a neutral recorded-call count rather than completion.
+They never acquire a running state from a later live turn. Individual call
+statuses and outputs remain readable after expansion; explicit failures and
+normal turn boundaries stay separate. This is a presentation change and applies
+to existing imports without rewriting or reimporting them.
+
+Source system/developer messages and host control envelopes are not activated.
+Encrypted reasoning is omitted with a warning. Plain reasoning summaries can be
+retained as historical text. Valid source compaction summaries affect only the
+request context; preceding imported entries remain readable. Supported images
+in Codex `replacement_history` survive as provenance-marked
+`stepcode.imported-compaction-attachments` custom messages after the compaction.
+These retain captions and enter model context without introducing synthetic user
+turns into source rollback accounting. Unsupported or missing retained images
+produce the same attachment warnings as ordinary messages. Continuing a long
+session may invoke StepCode's normal model-backed compaction, independently of
+the offline import operation.
+
+Inline supported images and image files contained in the selected source root
+are embedded in the target. Local images are capped at 20 MiB each and 32 MiB
+total, with MIME detection and fingerprinting. External paths, missing images and
+remote URLs are not fetched and are reported. Project files are not copied.
+Source sessions are capped at 128 MiB; discovery at 5,000 candidates and 16
+directory levels. Discovery inspects the first 1 MiB for readable titles without
+full adapter parsing, attachment loading, preview caching or source writes. It
+uses explicit Claude `custom-title`/`summary` records or Codex metadata `title` /
+`thread_name` (the Codex title index takes precedence), otherwise normalized first
+user text, then a project/source plus ID fallback. Titles are capped at 120
+characters. Fallback scan titles do not override the fully parsed preview title.
+Discovery decodes only complete JSONL records. Until identity metadata is found,
+it retains the existing complete-record read behavior within the 128 MiB source
+cap, including metadata-bearing records over 1 MiB. A final newline is not
+required. Other titles beyond the window require preview.
+Symlinks are not traversed during discovery.
+Codex date subdirectories can be selected directly. A selected path inside
+`archived_sessions` is still excluded unless archive inclusion is enabled.
+
+Web endpoints are specified in [the host OpenAPI contract](../../../apps/web/openapi.yaml).
+They use the host's existing cookie and Origin boundary. The browser always
+scans the **host** machine, not a remote browser device.
diff --git a/packages/coding-agent/src/core/session-import/content.ts b/packages/coding-agent/src/core/session-import/content.ts
new file mode 100644
index 00000000..1fa2dccd
--- /dev/null
+++ b/packages/coding-agent/src/core/session-import/content.ts
@@ -0,0 +1,60 @@
+const CONTROL_TAGS = [
+ "system-reminder",
+ "environment_context",
+ "permissions instructions",
+ "user_instructions",
+ "developer_instructions",
+ "turn_aborted",
+ "request_id",
+ "turn_id",
+ "model",
+ "command-name",
+ "command-message",
+ "command-args",
+ "local-command-caveat",
+ "local-command-stdout",
+ "local-command-stderr",
+];
+const PREFIX = new RegExp(`^\\s*<(${CONTROL_TAGS.join("|")})(?:\\s[^>]*)?>`);
+
+function unwrapUserQuery(value: string): string {
+ const start = /^\s*]*)?>/.exec(value);
+ if (!start) return value;
+ const tokens = /<\/?user_query(?:\s[^>]*)?>/g;
+ tokens.lastIndex = start[0].length;
+ let depth = 1;
+ for (let token = tokens.exec(value); token; token = tokens.exec(value)) {
+ depth += token[0].startsWith("") ? -1 : 1;
+ if (depth === 0) {
+ if (value.slice(tokens.lastIndex).trim()) return value;
+ return value.slice(start[0].length, token.index);
+ }
+ }
+ return value;
+}
+
+/** Remove source-host envelopes and unwrap complete user payloads, not fenced examples. */
+export function stripImportedUserControls(value: string): { text: string; removed: boolean } {
+ let text = value;
+ let removed = false;
+ for (;;) {
+ const first = PREFIX.exec(text);
+ if (!first) return { text: unwrapUserQuery(text), removed };
+ removed = true;
+ const tag = first[1];
+ const tokens = new RegExp(`<\\/?${tag}(?:\\s[^>]*)?>`, "g");
+ tokens.lastIndex = first[0].length;
+ let depth = 1;
+ let end = -1;
+ for (let token = tokens.exec(text); token; token = tokens.exec(text)) {
+ depth += token[0].startsWith("") ? -1 : 1;
+ if (depth === 0) {
+ end = tokens.lastIndex;
+ break;
+ }
+ }
+ // A partial source envelope is not a trustworthy user prompt.
+ if (end < 0) return { text: "", removed };
+ text = text.slice(end).trimStart();
+ }
+}
diff --git a/packages/coding-agent/src/core/session-import/parsers.ts b/packages/coding-agent/src/core/session-import/parsers.ts
new file mode 100644
index 00000000..7e815c53
--- /dev/null
+++ b/packages/coding-agent/src/core/session-import/parsers.ts
@@ -0,0 +1,438 @@
+import { randomBytes } from "node:crypto";
+import type { AssistantMessage, ImageContent, TextContent, ThinkingContent, ToolCall } from "@step-harness/providers";
+import type { SessionEntry } from "../session-manager.ts";
+import { stripImportedUserControls } from "./content.ts";
+import type { ImportSource, ImportWarning, ParsedImport } from "./types.ts";
+
+type RecordValue = Record;
+
+export function record(value: unknown): RecordValue {
+ return value && typeof value === "object" && !Array.isArray(value) ? (value as RecordValue) : {};
+}
+
+function text(value: unknown): string {
+ return typeof value === "string" ? value : "";
+}
+
+function list(value: unknown): unknown[] {
+ return Array.isArray(value) ? value : [];
+}
+
+function time(value: unknown): string {
+ const parsed = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : Number.NaN;
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : new Date(0).toISOString();
+}
+
+const EMPTY_USAGE: AssistantMessage["usage"] = {
+ input: 0,
+ output: 0,
+ cacheRead: 0,
+ cacheWrite: 0,
+ totalTokens: 0,
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+};
+
+/** Parse snapshots only. Never pass a foreign source through SessionManager.open. */
+export function parseForeignSession(source: ImportSource, input: string): ParsedImport {
+ const records: RecordValue[] = [];
+ for (const line of input.split(/\r?\n/u)) {
+ if (!line.trim()) continue;
+ try {
+ const value: unknown = JSON.parse(line);
+ const parsed = record(value);
+ if (typeof parsed.type !== "string") throw new Error("Missing record type");
+ records.push(parsed);
+ } catch {
+ throw new Error("Invalid JSONL snapshot; wait for the source to finish writing and scan again");
+ }
+ }
+ const meta = source === "codex" ? record(records.find((r) => r.type === "session_meta")?.payload) : {};
+ const first =
+ source === "codex"
+ ? meta
+ : (records.find((r) => typeof r.sessionId === "string" && typeof r.cwd === "string") ??
+ records.find((r) => typeof r.sessionId === "string") ??
+ {});
+ const sessionId = text(source === "codex" ? first.id : first.sessionId);
+ if (!sessionId) throw new Error("Source session ID is missing");
+ const result: ParsedImport = {
+ sessionId,
+ cwd: text(first.cwd),
+ title: "",
+ version: text(source === "codex" ? first.cli_version : first.version) || "unknown",
+ archived: records.some((r) => r.archived === true || record(r.payload).archived === true),
+ child:
+ source === "codex"
+ ? !!record(first.source).subagent || !!record(first.thread_source).subagent
+ : first.isSidechain === true || typeof first.parentAgentId === "string",
+ entries: [],
+ warnings: [],
+ };
+ const warnings = new Map();
+ const warn = (code: string) => warnings.set(code, (warnings.get(code) ?? 0) + 1);
+ let activeClaudeIds: Set | undefined;
+ if (source === "claude-code" || source === "qoder") {
+ const byId = new Map(records.filter((r) => typeof r.uuid === "string").map((r) => [text(r.uuid), r]));
+ const messages = records.filter((r) => (r.type === "user" || r.type === "assistant") && r.isSidechain !== true);
+ if (messages.some((r) => typeof r.parentUuid === "string")) {
+ activeClaudeIds = new Set();
+ const leaf = source === "qoder" ? text(records.filter((r) => r.type === "active-leaf").at(-1)?.leafUuid) : "";
+ let current = leaf ? byId.get(leaf) : messages.at(-1);
+ if (leaf && !current) throw new Error("Qoder active leaf is missing from the source snapshot");
+ while (current && typeof current.uuid === "string") {
+ if (activeClaudeIds.has(current.uuid)) throw new Error("Cycle in source message chain");
+ activeClaudeIds.add(current.uuid);
+ if (typeof current.parentUuid !== "string") break;
+ const parent = byId.get(current.parentUuid);
+ if (!parent) {
+ warn("branch_parent_missing");
+ break;
+ }
+ current = parent;
+ }
+ }
+ }
+ let parentId: string | null = null;
+ let sequence = 0;
+ const seen = new Set();
+ const append = (entry: SessionEntry, itemId: string, usage?: unknown) => {
+ if (seen.has(itemId)) return;
+ seen.add(itemId);
+ entry.id = randomBytes(8).toString("hex");
+ entry.parentId = parentId;
+ const historicalUsage = Object.fromEntries(
+ Object.entries(record(usage)).filter(([, n]) => typeof n === "number" && Number.isSafeInteger(n) && n >= 0),
+ );
+ entry.imported = {
+ source,
+ sessionId,
+ itemId,
+ ...(Object.keys(historicalUsage).length ? { historicalUsage: historicalUsage as Record } : {}),
+ };
+ parentId = entry.id;
+ result.entries.push(entry);
+ };
+ const base = (timestamp: unknown) => ({ id: "", parentId: null, timestamp: time(timestamp) });
+ const content = (value: unknown): (TextContent | ImageContent)[] => {
+ if (typeof value === "string") return value ? [{ type: "text", text: value }] : [];
+ const blocks: (TextContent | ImageContent)[] = [];
+ for (const raw of list(value)) {
+ const block = record(raw);
+ if (["text", "input_text", "output_text"].includes(text(block.type))) {
+ blocks.push({ type: "text", text: text(block.text) });
+ } else if (block.type === "image" && record(block.source).type === "base64") {
+ const image = record(block.source);
+ const mime = text(image.media_type);
+ const data = text(image.data);
+ if (/^image\/(?:png|jpeg|gif|webp)$/u.test(mime) && data.length <= 28_000_000)
+ blocks.push({ type: "image", data, mimeType: mime });
+ else warn("attachment_missing");
+ } else if (block.type === "input_image") {
+ const url = text(block.image_url);
+ const match = /^data:(image\/(?:png|jpeg|gif|webp));base64,([A-Za-z0-9+/=\r\n]+)$/u.exec(url);
+ if (match && match[2].length <= 28_000_000)
+ blocks.push({ type: "image", mimeType: match[1], data: match[2] });
+ else warn("attachment_missing");
+ } else if (block.type !== "tool_use" && block.type !== "tool_result" && block.type !== "thinking") {
+ warn("unsupported_content");
+ }
+ }
+ return blocks;
+ };
+ const assistant = (
+ blocks: (TextContent | ImageContent | ThinkingContent | ToolCall)[],
+ timestamp: unknown,
+ itemId: string,
+ usage?: unknown,
+ ) => {
+ const supported = blocks.filter((b): b is TextContent | ThinkingContent | ToolCall => b.type !== "image");
+ if (!supported.length) return;
+ append(
+ {
+ ...base(timestamp),
+ type: "message",
+ message: {
+ role: "assistant",
+ content: supported,
+ api: "imported",
+ provider: source,
+ model: "historical",
+ usage: structuredClone(EMPTY_USAGE),
+ stopReason: supported.some((b) => b.type === "toolCall") ? "toolUse" : "stop",
+ timestamp: Date.parse(time(timestamp)),
+ },
+ },
+ itemId,
+ usage,
+ );
+ };
+ const user = (blocks: (TextContent | ImageContent)[], timestamp: unknown, itemId: string) => {
+ const safeBlocks = blocks.flatMap((block): (TextContent | ImageContent)[] => {
+ if (block.type !== "text") return [block];
+ const cleaned = stripImportedUserControls(block.text);
+ if (cleaned.removed) warn("source_instructions_excluded");
+ return cleaned.text ? [{ ...block, text: cleaned.text }] : [];
+ });
+ if (!safeBlocks.length) return;
+ if (!result.title)
+ result.title = safeBlocks
+ .filter((b) => b.type === "text")
+ .map((b) => b.text)
+ .join(" ")
+ .slice(0, 120);
+ append(
+ {
+ ...base(timestamp),
+ type: "message",
+ message: { role: "user", content: safeBlocks, timestamp: Date.parse(time(timestamp)) },
+ },
+ itemId,
+ );
+ };
+ const calls = new Map();
+ const tool = (id: string, name: string, args: unknown): ToolCall => {
+ if (!id || !name) throw new Error("Tool call is missing its ID or name");
+ let parsed: unknown = args;
+ if (typeof args === "string") {
+ try {
+ parsed = JSON.parse(args);
+ } catch {
+ parsed = { raw: args };
+ warn("tool_arguments_as_text");
+ }
+ }
+ calls.set(id, name);
+ return { type: "toolCall", id, name, arguments: record(parsed) };
+ };
+ const toolResult = (
+ id: string,
+ output: unknown,
+ failed: boolean | undefined,
+ timestamp: unknown,
+ itemId: string,
+ ) => {
+ if (!id) throw new Error("Tool result is missing its call ID");
+ append(
+ {
+ ...base(timestamp),
+ type: "message",
+ message: {
+ role: "toolResult",
+ toolCallId: id,
+ toolName: calls.get(id) ?? "unknown",
+ content: content(typeof output === "object" && !Array.isArray(output) ? JSON.stringify(output) : output),
+ isError: failed === true,
+ details: { importedStatus: failed === undefined ? "unknown" : failed ? "failed" : "completed" },
+ timestamp: Date.parse(time(timestamp)),
+ },
+ },
+ itemId,
+ );
+ };
+ const hasResponseUsers = records.some(
+ (r) => r.type === "response_item" && record(r.payload).type === "message" && record(r.payload).role === "user",
+ );
+ for (const r of records) {
+ const fallbackId = `record-${sequence++}`;
+ const p = record(r.payload);
+ const id =
+ text(source === "codex" ? p.id : source === "workbuddy" ? r.id : r.uuid) ||
+ (source === "codex" && r.type === "response_item" && typeof p.call_id === "string"
+ ? `${text(p.type)}:${p.call_id}`
+ : fallbackId);
+ if (source === "codex") {
+ if (r.type === "event_msg" && p.type === "thread_rolled_back") {
+ const n = p.num_turns;
+ if (typeof n !== "number" || !Number.isSafeInteger(n) || n < 0) throw new Error("Unsupported rollback");
+ for (let i = 0; i < n; i++) {
+ let index = result.entries.length - 1;
+ while (index >= 0) {
+ const entry = result.entries[index];
+ if (entry.type === "message" && entry.message.role === "user") break;
+ index--;
+ }
+ if (index < 0) throw new Error("Incomplete history for rollback");
+ result.entries.splice(index);
+ }
+ parentId = result.entries.at(-1)?.id ?? null;
+ warn("rolled_back_history");
+ } else if (r.type === "event_msg" && p.type === "user_message" && !hasResponseUsers) {
+ user(content(p.message), r.timestamp, id);
+ } else if (r.type === "response_item") {
+ if (p.type === "message") {
+ if (p.role === "user") {
+ user(content(p.content), r.timestamp, id);
+ } else if (p.role === "assistant") assistant(content(p.content), r.timestamp, id);
+ else warn("source_instructions_excluded");
+ } else if (p.type === "function_call" || p.type === "custom_tool_call") {
+ assistant([tool(text(p.call_id), text(p.name), p.arguments ?? { input: p.input })], r.timestamp, id);
+ } else if (p.type === "function_call_output" || p.type === "custom_tool_call_output") {
+ toolResult(text(p.call_id), p.output, undefined, r.timestamp, id);
+ } else if (p.type === "reasoning") {
+ const summary = list(p.summary)
+ .map((s) => text(record(s).text))
+ .filter(Boolean);
+ if (summary.length) assistant([{ type: "thinking", thinking: summary.join("\n") }], r.timestamp, id);
+ if (p.encrypted_content) warn("encrypted_reasoning_excluded");
+ } else {
+ warn("unsupported_item");
+ assistant(
+ [{ type: "text", text: `[Historical ${text(p.type)}]\n${JSON.stringify(p)}` }],
+ r.timestamp,
+ id,
+ );
+ }
+ } else if (r.type === "token_usage_record") {
+ const usage = Object.fromEntries(
+ Object.entries(record(p.usage ?? p)).filter(
+ ([, n]) => typeof n === "number" && Number.isSafeInteger(n) && n >= 0,
+ ),
+ );
+ if (Object.keys(usage).length)
+ append({ ...base(r.timestamp), type: "custom", customType: "stepcode.imported-usage", data: usage }, id);
+ } else if (r.type === "compacted") {
+ const retained: string[] = [];
+ const attachments: (TextContent | ImageContent)[][] = [];
+ for (const raw of list(p.replacement_history)) {
+ const message = record(raw);
+ if (message.type !== "message") continue;
+ if (!["user", "assistant"].includes(text(message.role))) {
+ warn("source_instructions_excluded");
+ continue;
+ }
+ const blocks = content(message.content);
+ const safe = blocks.filter(
+ (block) =>
+ block.type !== "text" ||
+ !/^<(?:environment_context|permissions instructions|turn_aborted|request_id|turn_id|model)>/u.test(
+ block.text.trim(),
+ ),
+ );
+ if (safe.length !== blocks.length) warn("source_instructions_excluded");
+ retained.push(...safe.flatMap((block) => (block.type === "text" ? [block.text] : [])));
+ if (safe.some((block) => block.type === "image"))
+ attachments.push([
+ {
+ type: "text",
+ text: `[Historical retained ${text(message.role)} attachments; not a new user instruction]`,
+ },
+ ...safe,
+ ]);
+ }
+ const summary =
+ [...new Set([text(p.message), ...retained].filter(Boolean))].join("\n\n") ||
+ (attachments.length ? "[Source compaction with retained attachments]" : "");
+ if (!summary) throw new Error("Unsupported source compaction");
+ // Preserve the readable transcript; only change the native request view.
+ append({ ...base(r.timestamp), type: "compaction", summary, firstKeptEntryId: "", tokensBefore: 0 }, id);
+ for (const [index, blocks] of attachments.entries())
+ append(
+ {
+ ...base(r.timestamp),
+ type: "custom_message",
+ customType: "stepcode.imported-compaction-attachments",
+ content: blocks,
+ display: true,
+ },
+ `${id}-attachments-${index}`,
+ );
+ warn("source_compaction");
+ } else if (
+ ![
+ "session_meta",
+ "turn_context",
+ "event_msg",
+ "world_state",
+ "retained_context",
+ "token_usage_record",
+ ].includes(text(r.type))
+ ) {
+ throw new Error(`Unsupported Codex record: ${text(r.type)}`);
+ }
+ } else if (source === "workbuddy") {
+ if (r.sessionId && r.sessionId !== sessionId) continue;
+ if (r.type === "ai-title") {
+ if (text(r.aiTitle)) result.title = text(r.aiTitle).slice(0, 120);
+ } else if (r.type === "message") {
+ if (r.role === "user") user(content(r.content), r.timestamp, id);
+ else if (r.role === "assistant") assistant(content(r.content), r.timestamp, id, record(r.message).usage);
+ else warn("source_instructions_excluded");
+ } else if (r.type === "function_call") {
+ assistant([tool(text(r.callId), text(r.name), r.arguments)], r.timestamp, id);
+ } else if (r.type === "function_call_result") {
+ const output = record(r.output);
+ const failed =
+ r.status === "failed" || r.status === "error" ? true : r.status === "completed" ? false : undefined;
+ toolResult(text(r.callId), output.type === "text" ? output.text : r.output, failed, r.timestamp, id);
+ } else if (r.type === "reasoning") {
+ const thinking = content(r.content)
+ .flatMap((block) => (block.type === "text" ? [block.text] : []))
+ .join("\n");
+ if (thinking) assistant([{ type: "thinking", thinking }], r.timestamp, id);
+ else warn("unsupported_content");
+ } else if (!["session-meta", "file-history-snapshot"].includes(text(r.type))) {
+ warn("unsupported_item");
+ }
+ } else {
+ if (source === "qoder" && r.type === "attachment" && (!activeClaudeIds || activeClaudeIds.has(text(r.uuid))))
+ warn("unsupported_content");
+ if (r.isCompactSummary === true || (r.type === "system" && r.subtype === "compact_boundary"))
+ warn("claude_compaction");
+ if (r.isSidechain === true) {
+ warn("child_history_excluded");
+ continue;
+ }
+ if (r.type === "summary" || r.type === "custom-title") {
+ const title = text(r.customTitle ?? r.summary);
+ if (title) result.title = title.slice(0, 120);
+ }
+ if (r.type !== "user" && r.type !== "assistant") continue;
+ if (activeClaudeIds && !activeClaudeIds.has(text(r.uuid))) continue;
+ const message = record(r.message);
+ if (r.type === "user") {
+ user(content(message.content), r.timestamp, id);
+ for (const [i, raw] of list(message.content).entries()) {
+ const block = record(raw);
+ if (block.type === "tool_result")
+ toolResult(
+ text(block.tool_use_id),
+ block.content,
+ typeof block.is_error === "boolean" ? block.is_error : undefined,
+ r.timestamp,
+ `${id}-tool-${i}`,
+ );
+ }
+ } else {
+ const blocks: (TextContent | ImageContent | ThinkingContent | ToolCall)[] = [];
+ for (const raw of list(message.content)) {
+ const block = record(raw);
+ if (block.type === "tool_use") blocks.push(tool(text(block.id), text(block.name), block.input));
+ else if (block.type === "thinking") {
+ if (text(block.thinking)) blocks.push({ type: "thinking", thinking: text(block.thinking) });
+ } else blocks.push(...content([block]));
+ }
+ assistant(blocks, r.timestamp, id, message.usage);
+ }
+ }
+ }
+ const answered = new Set(
+ result.entries.flatMap((e) =>
+ e.type === "message" && e.message.role === "toolResult" ? [e.message.toolCallId] : [],
+ ),
+ );
+ for (const id of calls.keys()) if (!answered.has(id)) warn("tool_result_missing");
+ for (const e of result.entries) {
+ if (e.type === "message" && e.message.role === "toolResult" && !calls.has(e.message.toolCallId))
+ warn("tool_call_missing");
+ }
+ result.warnings = [...warnings].map(([code, count]): ImportWarning => ({ code, count }));
+ if (!result.entries.some((e) => e.type === "message" && e.message.role === "user"))
+ throw new Error("No supported user conversation found");
+ // Rebuild after rollback so no kept entry points to a removed node.
+ parentId = null;
+ for (const entry of result.entries) {
+ entry.parentId = parentId;
+ parentId = entry.id;
+ }
+ return result;
+}
diff --git a/packages/coding-agent/src/core/session-import/service.ts b/packages/coding-agent/src/core/session-import/service.ts
new file mode 100644
index 00000000..9063a51a
--- /dev/null
+++ b/packages/coding-agent/src/core/session-import/service.ts
@@ -0,0 +1,853 @@
+import { createHash, randomUUID } from "node:crypto";
+import {
+ link,
+ lstat,
+ mkdir,
+ open,
+ readdir,
+ readFile,
+ realpath,
+ rename,
+ stat,
+ unlink,
+ writeFile,
+} from "node:fs/promises";
+import { homedir } from "node:os";
+import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
+import { fileURLToPath } from "node:url";
+import type { FileEntry, SessionFilePersistedEvent } from "../session-manager.ts";
+import { stripImportedUserControls } from "./content.ts";
+import { parseForeignSession, record } from "./parsers.ts";
+import type {
+ ImportCandidate,
+ ImportDocument,
+ ImportJob,
+ ImportPreview,
+ ImportResult,
+ ImportSelection,
+ ImportSource,
+ ParsedImport,
+} from "./types.ts";
+import { isImportProvenance, isImportSource } from "./types.ts";
+
+const MAX_SOURCE_BYTES = 128 * 1024 * 1024;
+const HEADER_BYTES = 1024 * 1024;
+
+function readableTitle(value: unknown): string {
+ return typeof value === "string" ? value.replace(/\s+/gu, " ").trim().slice(0, 120) : "";
+}
+
+function userTitle(content: unknown): string {
+ if (typeof content === "string") return readableTitle(stripImportedUserControls(content).text);
+ if (!Array.isArray(content)) return "";
+ return readableTitle(
+ content
+ .filter((block) => ["text", "input_text"].includes(String(record(block).type)))
+ .map((block) => {
+ const value = record(block).text;
+ return typeof value === "string" ? stripImportedUserControls(value).text : "";
+ })
+ .filter((text) => typeof text === "string")
+ .join(" "),
+ );
+}
+
+function within(root: string, path: string): boolean {
+ const rel = relative(root, path);
+ return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
+}
+const MAX_CANDIDATES = 5000;
+const MAX_DEPTH = 16;
+const MAX_PREVIEW_BYTES = 256 * 1024 * 1024;
+const PREVIEW_TTL_MS = 15 * 60 * 1000;
+
+async function sourceAttachments(input: string, root: string): Promise<{ input: string; digest: string }> {
+ const fingerprints: string[] = [];
+ let total = 0;
+ const localImage = async (value: string): Promise<{ data: string; mime: string } | undefined> => {
+ if (!value || /^data:|^https?:/u.test(value)) return undefined;
+ try {
+ const path = await realpath(
+ value.startsWith("file:") ? fileURLToPath(value) : isAbsolute(value) ? value : join(root, value),
+ );
+ const rel = relative(root, path);
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return undefined;
+ const info = await stat(path);
+ if (!info.isFile() || info.size > 20 * 1024 * 1024 || total + info.size > 32 * 1024 * 1024) return undefined;
+ const data = await readFile(path);
+ const after = await stat(path);
+ if (info.size !== after.size || info.mtimeMs !== after.mtimeMs)
+ throw new SessionImportError("Attachment changed; preview again", 409);
+ const mime = data.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
+ ? "image/png"
+ : data[0] === 255 && data[1] === 216
+ ? "image/jpeg"
+ : data.subarray(0, 3).toString() === "GIF"
+ ? "image/gif"
+ : data.subarray(0, 4).toString() === "RIFF" && data.subarray(8, 12).toString() === "WEBP"
+ ? "image/webp"
+ : undefined;
+ if (!mime) return undefined;
+ total += data.length;
+ fingerprints.push(`${rel}:${createHash("sha256").update(data).digest("hex")}`);
+ return { data: data.toString("base64"), mime };
+ } catch (error) {
+ if (error instanceof SessionImportError) throw error;
+ if (["ENOENT", "EACCES", "EPERM", "EINVAL"].includes(String(record(error).code))) return undefined;
+ throw error;
+ }
+ };
+ const visit = async (value: unknown, depth: number): Promise => {
+ if (depth > 32) throw new SessionImportError("Source content nesting limit exceeded");
+ if (Array.isArray(value)) {
+ for (const child of value) await visit(child, depth + 1);
+ return;
+ }
+ const r = record(value);
+ if (r.type === "input_image" && typeof r.image_url === "string" && !r.image_url.startsWith("data:")) {
+ const image = await localImage(r.image_url);
+ if (image) r.image_url = `data:${image.mime};base64,${image.data}`;
+ }
+ if (r.type === "image" && record(r.source).type === "file" && typeof record(r.source).path === "string") {
+ const image = await localImage(String(record(r.source).path));
+ if (image) r.source = { type: "base64", media_type: image.mime, data: image.data };
+ }
+ for (const child of Object.values(r)) if (child && typeof child === "object") await visit(child, depth + 1);
+ };
+ const lines: string[] = [];
+ for (const line of input.split(/\r?\n/u)) {
+ if (!line.trim()) continue;
+ let value: unknown;
+ try {
+ value = JSON.parse(line);
+ } catch {
+ throw new SessionImportError("Invalid JSONL snapshot; wait for the source to finish writing");
+ }
+ await visit(value, 0);
+ lines.push(JSON.stringify(value));
+ }
+ return { input: lines.join("\n"), digest: fingerprints.join("\n") };
+}
+
+interface CandidateFile {
+ candidate: ImportCandidate;
+ path: string;
+ root: string;
+}
+
+interface ReviewedSnapshot {
+ data: string;
+ bytes: number;
+ expires: number;
+ users: number;
+}
+
+export interface ImportScanOptions {
+ customRoot?: string;
+ workbuddyRoot?: string;
+ qoderRoot?: string;
+ codexRoot?: string;
+ claudeRoot?: string;
+ includeArchived?: boolean;
+}
+
+export interface ImportScanResult {
+ existingCount: number;
+ roots: Partial>;
+ candidates: ImportCandidate[];
+ errors: { source: ImportSource; message: string }[];
+}
+
+export class SessionImportError extends Error {
+ readonly status: number;
+ constructor(message: string, status = 400) {
+ super(message);
+ this.status = status;
+ }
+}
+
+/** A local, offline migration service. Source files are opened read-only, never as live sessions. */
+export class SessionImportService {
+ private readonly agentDir: string;
+ private readonly sessionRoot: string;
+ private readonly onPersisted?: (event: SessionFilePersistedEvent) => void;
+ private readonly candidates = new Map();
+ private readonly explicitTitles = new WeakSet();
+ private readonly jobs = new Map();
+ private readonly running = new Map>();
+ private readonly cancelled = new Set();
+ private readonly previews = new Map();
+ private queue: Promise = Promise.resolve();
+ private closed = false;
+
+ constructor(options: {
+ agentDir: string;
+ sessionRoot?: string;
+ onPersisted?: (event: SessionFilePersistedEvent) => void;
+ }) {
+ this.agentDir = resolve(options.agentDir);
+ this.sessionRoot = resolve(options.sessionRoot ?? join(this.agentDir, "sessions"));
+ this.onPersisted = options.onPersisted;
+ }
+
+ async scan(options: ImportScanOptions = {}): Promise {
+ if (this.closed) throw new SessionImportError("Importer is closed", 503);
+ const result: ImportScanResult = { existingCount: 0, roots: {}, candidates: [], errors: [] };
+ const roots: [ImportSource, string][] = [
+ [
+ "workbuddy",
+ options.customRoot?.trim() || options.workbuddyRoot?.trim() || join(homedir(), ".workbuddy", "projects"),
+ ],
+ ["qoder", options.customRoot?.trim() || options.qoderRoot?.trim() || join(homedir(), ".qoder", "projects")],
+ [
+ "codex",
+ options.customRoot?.trim() ||
+ options.codexRoot?.trim() ||
+ process.env.CODEX_HOME?.trim() ||
+ join(homedir(), ".codex"),
+ ],
+ [
+ "claude-code",
+ options.customRoot?.trim() ||
+ options.claudeRoot?.trim() ||
+ join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude"), "projects"),
+ ],
+ ];
+ // Custom scans append capabilities; automatic scans replace them after discovery settles.
+ const candidates = new Map(options.customRoot ? this.candidates : []);
+ const knownPaths = new Set([...candidates.values()].map((file) => `${file.candidate.source}\0${file.path}`));
+ for (const [source, input] of roots) {
+ try {
+ const root = await realpath(input);
+ if (!options.customRoot) result.roots[source] = root;
+ if (root.split(sep).includes("archived_sessions") && !options.includeArchived) continue;
+ const rootEntries = await readdir(root, { withFileTypes: true });
+ const codexHome =
+ source === "codex" &&
+ rootEntries.some(
+ (entry) =>
+ (entry.isDirectory() && ["sessions", "archived_sessions"].includes(entry.name)) ||
+ entry.name === "session_index.jsonl",
+ );
+ const titles = new Map();
+ if (source === "codex") {
+ const index = join(root, "session_index.jsonl");
+ try {
+ const info = await stat(index);
+ if (info.size > 32 * 1024 * 1024) throw new Error("Source title index exceeds 32 MiB");
+ for (const line of (await readFile(index, "utf8")).split(/\r?\n/u)) {
+ if (!line.trim()) continue;
+ const entry = record(JSON.parse(line));
+ if (typeof entry.id === "string" && typeof entry.thread_name === "string")
+ titles.set(entry.id, entry.thread_name.slice(0, 120));
+ }
+ } catch (error) {
+ if (record(error).code !== "ENOENT")
+ result.errors.push({
+ source,
+ message: `Title index: ${error instanceof Error ? error.message : String(error)}`,
+ });
+ }
+ }
+ const walk = async (dir: string, depth: number): Promise => {
+ if (depth > MAX_DEPTH) throw new Error("Source directory depth limit exceeded");
+ const files = await readdir(dir, { withFileTypes: true });
+ for (const file of files) {
+ if (this.closed) throw new Error("Importer is closed");
+ if (file.isSymbolicLink()) continue;
+ const archived = file.name === "archived_sessions";
+ if (archived && !options.includeArchived) continue;
+ if (
+ ["node_modules", ".git", "attachments", "worktrees", "backups", "backups_state"].includes(
+ file.name,
+ )
+ )
+ continue;
+ const path = join(dir, file.name);
+ if (file.isDirectory()) {
+ if (
+ source === "codex" &&
+ codexHome &&
+ dir === root &&
+ !["sessions", "archived_sessions"].includes(file.name)
+ )
+ continue;
+ await walk(path, depth + 1);
+ } else if (
+ file.isFile() &&
+ file.name.endsWith(".jsonl") &&
+ (!!options.customRoot || source !== "codex" || file.name.startsWith("rollout-"))
+ ) {
+ const key = `${source}\0${path}`;
+ if (knownPaths.has(key)) {
+ result.existingCount++;
+ result.roots[source] = root;
+ continue;
+ }
+ if (candidates.size >= MAX_CANDIDATES)
+ throw new Error("Session limit exceeded; select a narrower source directory");
+ try {
+ const candidate = await this.discover(source, path, root, !!options.customRoot);
+ if (!candidate || (candidate.archived && !options.includeArchived)) continue;
+ const indexedTitle = readableTitle(titles.get(candidate.sessionId));
+ if (indexedTitle) {
+ candidate.title = indexedTitle;
+ this.explicitTitles.add(candidate);
+ }
+ candidates.set(candidate.id, { candidate, path, root });
+ knownPaths.add(key);
+ result.candidates.push(candidate);
+ result.roots[source] = root;
+ } catch (error) {
+ result.errors.push({
+ source,
+ message: `${file.name}: ${error instanceof Error ? error.message : String(error)}`,
+ });
+ }
+ }
+ }
+ };
+ await walk(root, 0);
+ } catch (error) {
+ // An absent optional client is normal; permission and parse errors must remain visible.
+ if (options.customRoot || record(error).code !== "ENOENT")
+ result.errors.push({ source, message: error instanceof Error ? error.message : String(error) });
+ }
+ }
+ this.candidates.clear();
+ for (const [id, candidate] of candidates) this.candidates.set(id, candidate);
+ result.candidates.sort((a, b) => b.modified.localeCompare(a.modified));
+ return result;
+ }
+
+ private async discover(
+ source: ImportSource,
+ path: string,
+ root: string,
+ allowOtherFormat = false,
+ ): Promise {
+ const handle = await open(path, "r");
+ try {
+ const info = await handle.stat();
+ if (!info.isFile() || info.size > MAX_SOURCE_BYTES)
+ throw new Error("Source is not a regular file or exceeds 128 MiB");
+ let metadata: Record | undefined;
+ let detected: ImportSource | undefined;
+ let projectCwd = "";
+ let title = "";
+ let firstUser = "";
+ let pending: Buffer[] = [];
+ const inspect = (bytes: Buffer): void => {
+ const line = bytes.toString("utf8");
+ if (!line.trim()) return;
+ let value: unknown;
+ try {
+ value = JSON.parse(line);
+ } catch (error) {
+ // Only preview validates the transcript after discovery has established its identity.
+ if (!metadata) throw error;
+ return;
+ }
+ const r = record(value);
+ if (r.type === "workspace-directories" || r.type === "runtime-config") detected = "qoder";
+ if (r.type === "session-meta" || r.type === "ai-title") detected = "workbuddy";
+ if (typeof r.cwd === "string" && !projectCwd) projectCwd = r.cwd;
+ if (source === "codex") {
+ const p = record(r.payload);
+ if (r.type === "session_meta") {
+ metadata ??= p;
+ title = readableTitle(p.title) || readableTitle(p.thread_name) || title;
+ }
+ if (!firstUser && r.type === "response_item" && p.type === "message" && p.role === "user")
+ firstUser = userTitle(p.content);
+ if (!firstUser && r.type === "event_msg" && p.type === "user_message") firstUser = userTitle(p.message);
+ } else if (source === "workbuddy") {
+ if (r.type === "session-meta" && typeof r.sessionId === "string") metadata ??= r;
+ if (r.type === "ai-title") title = readableTitle(r.aiTitle) || title;
+ if (r.type === "message" && r.role === "user" && !firstUser) firstUser = userTitle(r.content);
+ } else {
+ if ((r.type === "user" || r.type === "assistant") && typeof r.sessionId === "string") metadata ??= r;
+ if (r.isSidechain === true || (metadata && r.sessionId && r.sessionId !== metadata.sessionId)) return;
+ if (r.type === "custom-title" || r.type === "summary")
+ title = readableTitle(r.customTitle) || readableTitle(r.summary) || title;
+ else title = readableTitle(r.customTitle) || title;
+ if (!firstUser && r.type === "user") firstUser = userTitle(record(r.message).content);
+ }
+ };
+ // Title reads stop at 1 MiB. Metadata may share a larger record with message/image content;
+ // retain complete-record discovery up to the source cap until identity is established.
+ let position = 0;
+ while (position < info.size && (!metadata || position < HEADER_BYTES)) {
+ const limit = metadata ? Math.min(info.size, HEADER_BYTES) : info.size;
+ const buffer = Buffer.alloc(Math.min(limit - position, 64 * 1024));
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
+ if (!bytesRead) break;
+ position += bytesRead;
+ const chunk = buffer.subarray(0, bytesRead);
+ let start = 0;
+ for (let end = chunk.indexOf(10); end !== -1; end = chunk.indexOf(10, start)) {
+ pending.push(chunk.subarray(start, end));
+ inspect(Buffer.concat(pending));
+ pending = [];
+ start = end + 1;
+ if (metadata && position - chunk.length + start >= HEADER_BYTES) {
+ start = chunk.length;
+ break;
+ }
+ }
+ if (start < chunk.length) pending.push(chunk.subarray(start));
+ }
+ if (position === info.size && pending.length) inspect(Buffer.concat(pending));
+ if (
+ (detected && detected !== source) ||
+ (source === "qoder" && detected !== "qoder") ||
+ (source === "workbuddy" && detected !== "workbuddy")
+ )
+ return undefined;
+ if (!metadata) {
+ if (allowOtherFormat) return undefined;
+ throw new Error("Session metadata is missing from the bounded header");
+ }
+ if (
+ metadata.isSidechain === true ||
+ metadata.parentAgentId ||
+ record(metadata.source).subagent ||
+ record(metadata.thread_source).subagent
+ )
+ return undefined;
+ const sessionId = source === "codex" ? metadata.id : metadata.sessionId;
+ if (typeof sessionId !== "string" || !sessionId) throw new Error("Invalid source session ID");
+ const cwd = typeof metadata.cwd === "string" ? metadata.cwd : projectCwd;
+ const archived =
+ relative(root, path).split(sep).includes("archived_sessions") ||
+ root.split(sep).includes("archived_sessions") ||
+ metadata.archived === true;
+ const candidate: ImportCandidate = {
+ id: randomUUID(),
+ sourcePath: path,
+ source,
+ sessionId,
+ title: title || firstUser || `${basename(cwd) || source} · ${sessionId.slice(0, 8)}`,
+ cwd,
+ modified: info.mtime.toISOString(),
+ archived,
+ version:
+ typeof metadata.cli_version === "string"
+ ? metadata.cli_version
+ : typeof metadata.version === "string"
+ ? metadata.version
+ : "unknown",
+ };
+ if (title) this.explicitTitles.add(candidate);
+ return candidate;
+ } finally {
+ await handle.close();
+ }
+ }
+
+ private async snapshot(
+ id: string,
+ file = this.candidates.get(id),
+ ): Promise<{ parsed: ParsedImport; preview: ImportPreview }> {
+ if (!file) throw new SessionImportError("Candidate expired; scan again", 409);
+ const path = await realpath(file.path);
+ const rel = relative(file.root, path);
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel) || (await lstat(file.path)).isSymbolicLink())
+ throw new SessionImportError("Source escaped its selected directory");
+ const handle = await open(path, "r");
+ let input: string;
+ try {
+ const before = await handle.stat();
+ if (!before.isFile() || before.size > MAX_SOURCE_BYTES) throw new SessionImportError("Source exceeds 128 MiB");
+ input = await handle.readFile("utf8");
+ const after = await handle.stat();
+ const current = await stat(path);
+ if (
+ before.size !== after.size ||
+ before.mtimeMs !== after.mtimeMs ||
+ before.ino !== current.ino ||
+ current.size !== after.size ||
+ current.mtimeMs !== after.mtimeMs
+ )
+ throw new SessionImportError("Source changed while reading; preview again", 409);
+ } finally {
+ await handle.close();
+ }
+ const attachments = await sourceAttachments(input, file.root);
+ const parsed = parseForeignSession(file.candidate.source, attachments.input);
+ if (parsed.child || parsed.sessionId !== file.candidate.sessionId)
+ throw new SessionImportError("Source identity changed", 409);
+ if (parsed.archived && !file.candidate.archived)
+ throw new SessionImportError("Source was archived; scan again", 409);
+ const messages = parsed.entries.filter((e) => e.type === "message");
+ return {
+ parsed,
+ preview: {
+ ...file.candidate,
+ title: this.explicitTitles.has(file.candidate)
+ ? file.candidate.title
+ : parsed.title || file.candidate.title,
+ fingerprint: createHash("sha256")
+ .update(input)
+ .update(attachments.digest ? `\0${attachments.digest}` : "")
+ .digest("hex"),
+ messageCount: messages.length,
+ toolCount: messages.reduce(
+ (n, e) =>
+ n +
+ (e.message.role === "assistant" ? e.message.content.filter((b) => b.type === "toolCall").length : 0),
+ 0,
+ ),
+ warnings: parsed.warnings,
+ excerpt: messages
+ .flatMap((e) => {
+ const m = e.message;
+ if (!("content" in m)) return [];
+ const body =
+ typeof m.content === "string"
+ ? m.content
+ : m.content
+ .filter((b) => b.type === "text")
+ .map((b) => b.text)
+ .join("\n");
+ return body ? [`${m.role}: ${body}`] : [];
+ })
+ .join("\n\n")
+ .slice(0, 12000),
+ },
+ };
+ }
+
+ async preview(id: string): Promise {
+ if (this.closed) throw new SessionImportError("Importer is closed", 503);
+ const { parsed, preview } = await this.snapshot(id);
+ if (this.closed) throw new SessionImportError("Importer is closed", 503);
+ const key = `${id}:${preview.fingerprint}`;
+ const existing = this.previews.get(key);
+ if (existing) {
+ existing.expires = Date.now() + PREVIEW_TTL_MS;
+ return preview;
+ }
+ const document: ImportDocument = { preview, entries: parsed.entries };
+ const data = JSON.stringify(document);
+ const bytes = data.length * 2;
+ if (bytes > MAX_PREVIEW_BYTES) throw new SessionImportError("Preview snapshot exceeds cache limit", 413);
+ this.prunePreviews();
+ let used = [...this.previews.values()].reduce((sum, entry) => sum + entry.bytes, 0);
+ for (const [oldKey, entry] of this.previews) {
+ if (used + bytes <= MAX_PREVIEW_BYTES) break;
+ if (entry.users) continue;
+ this.previews.delete(oldKey);
+ used -= entry.bytes;
+ }
+ if (used + bytes > MAX_PREVIEW_BYTES)
+ throw new SessionImportError("Preview cache is busy; wait for current imports to finish", 409);
+ this.previews.set(key, { data, bytes, expires: Date.now() + PREVIEW_TTL_MS, users: 0 });
+ return preview;
+ }
+
+ private prunePreviews(): void {
+ for (const [key, entry] of this.previews) {
+ if (!entry.users && entry.expires <= Date.now()) this.previews.delete(key);
+ }
+ }
+
+ private async saveJob(job: ImportJob): Promise {
+ const dir = join(this.agentDir, "session-imports");
+ await mkdir(dir, { recursive: true });
+ const path = join(dir, `${job.id}.json`);
+ const temporary = `${path}.${randomUUID()}.tmp`;
+ try {
+ await writeFile(temporary, JSON.stringify(job), { flag: "wx", mode: 0o600 });
+ await rename(temporary, path);
+ } finally {
+ await unlink(temporary).catch((error: unknown) => {
+ // A successful rename has already removed the temporary directory entry.
+ if (record(error).code !== "ENOENT") throw error;
+ });
+ }
+ }
+
+ async listJobs(): Promise {
+ const dir = join(this.agentDir, "session-imports");
+ let names: string[];
+ try {
+ names = await readdir(dir);
+ } catch (error) {
+ if (record(error).code === "ENOENT") return [];
+ throw error;
+ }
+ const jobs: ImportJob[] = [];
+ for (const name of names.filter((n) => /^[a-f0-9-]{36}\.json$/u.test(n))) {
+ const id = name.slice(0, -5);
+ const job = await this.getJob(id);
+ jobs.push(job);
+ }
+ return jobs;
+ }
+
+ async getJob(id: string): Promise {
+ if (!/^[a-f0-9-]{36}$/u.test(id)) throw new SessionImportError("Invalid job ID");
+ const active = this.jobs.get(id);
+ if (active) return structuredClone(active);
+ let value: unknown;
+ try {
+ value = JSON.parse(await readFile(join(this.agentDir, "session-imports", `${id}.json`), "utf8"));
+ } catch (error) {
+ if (record(error).code === "ENOENT") throw new SessionImportError("Job not found", 404);
+ throw error;
+ }
+ const r = record(value);
+ if (
+ r.id !== id ||
+ !["running", "completed", "cancelled", "interrupted"].includes(String(r.status)) ||
+ !Number.isSafeInteger(r.total) ||
+ Number(r.total) < 1 ||
+ Number(r.total) > 100 ||
+ !Array.isArray(r.results)
+ )
+ throw new SessionImportError("Invalid persisted import job", 500);
+ // Only this service owns live jobs; a previous host's unfinished batch cannot still be running.
+ for (const value of r.results) {
+ const result = record(value);
+ if (
+ typeof result.candidateId !== "string" ||
+ !["imported", "skipped", "failed"].includes(String(result.status)) ||
+ (result.source !== undefined && !isImportSource(result.source)) ||
+ (result.sourcePath !== undefined && typeof result.sourcePath !== "string") ||
+ (result.sessionId !== undefined && typeof result.sessionId !== "string") ||
+ (result.path !== undefined && typeof result.path !== "string") ||
+ (result.error !== undefined && typeof result.error !== "string") ||
+ (result.warning !== undefined && typeof result.warning !== "string") ||
+ (result.cwd !== undefined &&
+ (typeof result.cwd !== "string" || !isAbsolute(result.cwd) || result.cwd.includes("\0"))) ||
+ (result.title !== undefined && typeof result.title !== "string")
+ )
+ throw new SessionImportError("Invalid persisted import result", 500);
+ }
+ if (typeof r.createdAt !== "string" || !Number.isFinite(Date.parse(r.createdAt)))
+ throw new SessionImportError("Invalid persisted import date", 500);
+ // The envelope and every result were validated at this persistence boundary.
+ const job = value as ImportJob;
+ for (const result of job.results) {
+ if (!result.path || (result.source && result.cwd && result.title)) continue;
+ try {
+ const root = await realpath(join(this.sessionRoot, "imported"));
+ const path = await realpath(result.path);
+ if (!within(root, path)) continue;
+ const handle = await open(path, "r");
+ try {
+ const info = await handle.stat();
+ if (!info.isFile()) continue;
+ const buffer = Buffer.alloc(Math.min(info.size, HEADER_BYTES));
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
+ const bytes = buffer.subarray(0, bytesRead);
+ // Discard an incomplete final record, except at the actual file end.
+ const complete = bytesRead === info.size ? bytes : bytes.subarray(0, bytes.lastIndexOf(10) + 1);
+ let firstUser = "";
+ let name = "";
+ for (const line of complete.toString("utf8").split(/\r?\n/u)) {
+ let value: unknown;
+ try {
+ value = JSON.parse(line);
+ } catch {
+ continue;
+ }
+ const entry = record(value);
+ if (
+ entry.type === "session" &&
+ typeof entry.cwd === "string" &&
+ isAbsolute(entry.cwd) &&
+ !entry.cwd.includes("\0")
+ )
+ result.cwd ??= entry.cwd;
+ if (entry.type === "session_info") name = readableTitle(entry.name) || name;
+ const message = record(entry.message);
+ if (!firstUser && entry.type === "message" && message.role === "user")
+ firstUser = userTitle(message.content);
+ if (!result.source && isImportProvenance(entry.imported)) result.source = entry.imported.source;
+ }
+ if (!result.title) {
+ // Native imported names have a display prefix; journal titles do not.
+ const title = name.replace(/^\[(codex|claude-code)\] /u, "") || firstUser;
+ if (title) result.title = title;
+ }
+ } finally {
+ await handle.close();
+ }
+ } catch {
+ // Deleted or inaccessible native sessions leave truthful unknown metadata in old journals.
+ }
+ }
+ if (job.status === "running") {
+ job.status = "interrupted";
+ await this.saveJob(job);
+ }
+ return job;
+ }
+
+ async start(selections: ImportSelection[]): Promise {
+ if (this.closed) throw new SessionImportError("Importer is closed", 503);
+ if (!selections.length || selections.length > 100)
+ throw new SessionImportError("Select between 1 and 100 sessions");
+ this.prunePreviews();
+ const submitted = selections.map((selection) => {
+ const file = this.candidates.get(selection.candidateId);
+ if (!file || !/^[a-f0-9]{64}$/u.test(selection.fingerprint) || !isAbsolute(selection.cwd))
+ throw new SessionImportError("Invalid selection; preview and choose an absolute project directory");
+ const snapshot = this.previews.get(`${selection.candidateId}:${selection.fingerprint}`);
+ if (!snapshot || snapshot.expires <= Date.now())
+ throw new SessionImportError("Preview expired; preview the selected session again", 409);
+ return { selection: { ...selection }, file, snapshot };
+ });
+ const job: ImportJob = {
+ id: randomUUID(),
+ createdAt: new Date().toISOString(),
+ status: "running",
+ total: selections.length,
+ results: [],
+ };
+ // Pin before the first await so scans, eviction and other jobs cannot replace reviewed content.
+ for (const { snapshot } of submitted) snapshot.users++;
+ try {
+ await this.saveJob(job);
+ } catch (error) {
+ for (const { snapshot } of submitted) snapshot.users--;
+ throw error;
+ }
+ this.jobs.set(job.id, job);
+ const run = this.queue
+ .then(async () => {
+ for (const { selection, file, snapshot } of submitted) {
+ if (this.cancelled.has(job.id) || this.closed) break;
+ let result: ImportResult;
+ try {
+ result = await this.commit(selection, snapshot);
+ } catch (error) {
+ result = {
+ candidateId: selection.candidateId,
+ source: file.candidate.source,
+ sourcePath: file.path,
+ title: file.candidate.title,
+ cwd: selection.cwd,
+ status: "failed",
+ error: error instanceof Error ? error.message : String(error),
+ };
+ }
+ job.results.push(result);
+ await this.saveJob(job);
+ }
+ job.status = this.cancelled.has(job.id) || this.closed ? "cancelled" : "completed";
+ await this.saveJob(job);
+ })
+ .catch(async (error: unknown) => {
+ job.status = "interrupted";
+ job.results.push({
+ candidateId: "",
+ status: "failed",
+ error: `Import journal failed: ${error instanceof Error ? error.message : String(error)}`,
+ });
+ // Keep the in-memory terminal failure available even if the disk journal is unavailable.
+ })
+ .finally(() => {
+ for (const { snapshot } of submitted) snapshot.users--;
+ this.prunePreviews();
+ this.running.delete(job.id);
+ this.cancelled.delete(job.id);
+ });
+ this.queue = run;
+ this.running.set(job.id, run);
+ return structuredClone(job);
+ }
+
+ async cancel(id: string): Promise {
+ const job = await this.getJob(id);
+ if (job.status === "running") this.cancelled.add(id);
+ return job;
+ }
+
+ async close(): Promise {
+ this.closed = true;
+ await this.queue;
+ this.previews.clear();
+ }
+
+ private async commit(selection: ImportSelection, snapshot: ReviewedSnapshot): Promise {
+ // This immutable document was serialized by preview(), not supplied by the client or source.
+ const { entries: reviewedEntries, preview } = JSON.parse(snapshot.data) as ImportDocument;
+ const cwd = await realpath(selection.cwd);
+ if (!(await stat(cwd)).isDirectory()) throw new SessionImportError("Project directory does not exist");
+ const hash = createHash("sha256")
+ .update(`session-import-v2\0${preview.source}\0${preview.sessionId}\0${preview.fingerprint}`)
+ .digest("hex");
+ const id = `${hash.slice(0, 8)}-${hash.slice(8, 12)}-4${hash.slice(13, 16)}-a${hash.slice(17, 20)}-${hash.slice(20, 32)}`;
+ const dir = join(this.sessionRoot, "imported");
+ await mkdir(dir, { recursive: true });
+ const path = join(dir, `${id}.jsonl`);
+ const temporary = join(dir, `.${id}.${randomUUID()}.tmp`);
+ const timestamp = new Date().toISOString();
+ const last = reviewedEntries.at(-1);
+ const leaf = last && "id" in last ? last.id : null;
+ const entries: FileEntry[] = [
+ { type: "session", version: 3, id, timestamp, cwd },
+ ...reviewedEntries,
+ {
+ type: "custom",
+ id: randomUUID().slice(0, 8),
+ parentId: leaf,
+ timestamp,
+ customType: "stepcode.session-import",
+ data: {
+ converterVersion: 2,
+ source: preview.source,
+ sourceSessionId: preview.sessionId,
+ fingerprint: preview.fingerprint,
+ version: preview.version,
+ sourceCwd: preview.cwd,
+ warnings: preview.warnings,
+ },
+ },
+ ];
+ const metadataId = entries.at(-1);
+ entries.push({
+ type: "session_info",
+ id: randomUUID().slice(0, 8),
+ parentId: metadataId && "parentId" in metadataId ? metadataId.id : leaf,
+ timestamp,
+ name: `[${preview.source}] ${preview.title}`,
+ });
+ const handle = await open(temporary, "wx", 0o600);
+ let status: "imported" | "skipped" = "imported";
+ try {
+ await handle.writeFile(`${entries.map((e) => JSON.stringify(e)).join("\n")}\n`);
+ await handle.sync();
+ await handle.close();
+ // A hard-link publication is atomic and, unlike rename, never overwrites another importer.
+ await link(temporary, path);
+ } catch (error) {
+ if (record(error).code === "EEXIST") status = "skipped";
+ else throw error;
+ } finally {
+ await handle.close();
+ await unlink(temporary);
+ }
+ let warning: string | undefined;
+ try {
+ this.onPersisted?.({ path, kind: "append" });
+ } catch (error) {
+ // Publication is already durable. An index failure must not misreport it as a failed import.
+ warning = `Session saved; search index notification failed: ${error instanceof Error ? error.message : String(error)}`;
+ }
+ return {
+ candidateId: selection.candidateId,
+ source: preview.source,
+ sourcePath: preview.sourcePath,
+ title: preview.title,
+ cwd: selection.cwd,
+ status,
+ sessionId: id,
+ path,
+ warning,
+ };
+ }
+}
diff --git a/packages/coding-agent/src/core/session-import/types.ts b/packages/coding-agent/src/core/session-import/types.ts
new file mode 100644
index 00000000..e94985e8
--- /dev/null
+++ b/packages/coding-agent/src/core/session-import/types.ts
@@ -0,0 +1,90 @@
+import type { FileEntry, SessionEntry } from "../session-manager.ts";
+
+export type ImportSource = "codex" | "claude-code" | "workbuddy" | "qoder";
+
+export interface ImportProvenance {
+ source: ImportSource;
+ sessionId: string;
+ itemId: string;
+ historicalUsage?: Record;
+}
+
+export interface ImportCandidate {
+ id: string;
+ source: ImportSource;
+ sessionId: string;
+ title: string;
+ cwd: string;
+ modified: string;
+ archived: boolean;
+ version: string;
+ /** Absolute source JSONL path discovered by the offline scanner. */
+ sourcePath?: string;
+}
+
+export interface ImportWarning {
+ code: string;
+ count: number;
+}
+
+export interface ImportPreview extends ImportCandidate {
+ fingerprint: string;
+ messageCount: number;
+ toolCount: number;
+ warnings: ImportWarning[];
+ excerpt: string;
+}
+
+export interface ParsedImport {
+ sessionId: string;
+ cwd: string;
+ title: string;
+ version: string;
+ archived: boolean;
+ child: boolean;
+ entries: SessionEntry[];
+ warnings: ImportWarning[];
+}
+
+export interface ImportSelection {
+ candidateId: string;
+ fingerprint: string;
+ cwd: string;
+}
+
+export interface ImportResult {
+ candidateId: string;
+ source?: ImportSource;
+ sourcePath?: string;
+ title?: string;
+ /** Selected absolute destination project directory, not the source cwd. */
+ cwd?: string;
+ status: "imported" | "skipped" | "failed";
+ sessionId?: string;
+ path?: string;
+ error?: string;
+ warning?: string;
+}
+
+export interface ImportJob {
+ id: string;
+ createdAt: string;
+ status: "running" | "completed" | "cancelled" | "interrupted";
+ total: number;
+ results: ImportResult[];
+}
+
+export interface ImportDocument {
+ preview: ImportPreview;
+ entries: FileEntry[];
+}
+
+export function isImportSource(value: unknown): value is ImportSource {
+ return value === "codex" || value === "claude-code" || value === "workbuddy" || value === "qoder";
+}
+
+export function isImportProvenance(value: unknown): value is ImportProvenance {
+ if (!value || typeof value !== "object") return false;
+ const record = value as Record;
+ return isImportSource(record.source) && typeof record.sessionId === "string" && typeof record.itemId === "string";
+}
diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts
index 6359cada..2f331ec0 100644
--- a/packages/coding-agent/src/core/session-manager.ts
+++ b/packages/coding-agent/src/core/session-manager.ts
@@ -27,6 +27,8 @@ import {
createCompactionSummaryMessage,
createCustomMessage,
} from "./messages.ts";
+import { stripImportedUserControls } from "./session-import/content.ts";
+import { type ImportProvenance, isImportProvenance } from "./session-import/types.ts";
import { createSessionSummary, foldSessionSummaryEntry, resolveSessionModifiedTime } from "./session-summary.ts";
import { createUsageLineage } from "./usage-lineage.ts";
@@ -58,6 +60,8 @@ export interface SessionEntryBase {
id: string;
parentId: string | null;
timestamp: string;
+ /** Historical provenance; imported controls and usage never become live session state. */
+ imported?: ImportProvenance;
}
export interface SessionMessageEntry extends SessionEntryBase {
@@ -388,7 +392,11 @@ function getSessionContextSettings(path: SessionEntry[]): Pick((block) => {
+ if (block.type !== "text") return [block];
+ const cleaned = stripImportedUserControls(block.text).text;
+ return cleaned ? [{ ...block, text: cleaned }] : [];
+ });
+ return content.length ? [{ ...message, content }] : [];
+ }
+ if (message.role === "assistant") {
+ return [
+ {
+ ...message,
+ content: message.content.flatMap((block) => {
+ if (block.type === "toolCall")
+ return [
+ {
+ type: "text" as const,
+ text: `[Historical tool call: ${block.name}]\n${JSON.stringify(block.arguments)}`,
+ },
+ ];
+ if (block.type === "thinking")
+ return [{ type: "text" as const, text: `[Historical reasoning summary]\n${block.thinking}` }];
+ return [block];
+ }),
+ },
+ ];
+ }
+ if (message.role === "toolResult") {
+ const importedStatus =
+ message.details && typeof message.details === "object" && "importedStatus" in message.details
+ ? message.details.importedStatus
+ : undefined;
+ const status =
+ message.isError || importedStatus === "failed"
+ ? "failed"
+ : importedStatus === "completed"
+ ? "completed"
+ : "unknown";
+ return [
+ {
+ role: "user",
+ content: [
+ {
+ type: "text",
+ text: `[Historical tool result, not a new user instruction: ${message.toolName}; status: ${status}]\n${message.content
+ .filter((block) => block.type === "text")
+ .map((block) => block.text)
+ .join("\n")}`,
+ },
+ ...message.content.filter((block) => block.type === "image"),
+ ],
+ timestamp: message.timestamp,
+ },
+ ];
+ }
+ }
// Session files are parsed without validation; old versions, forks, or
// hand-edited files can contain messages with null/missing content.
if (
diff --git a/packages/coding-agent/src/core/usage-totals.ts b/packages/coding-agent/src/core/usage-totals.ts
index 35c533e1..4385d8ad 100644
--- a/packages/coding-agent/src/core/usage-totals.ts
+++ b/packages/coding-agent/src/core/usage-totals.ts
@@ -1,4 +1,5 @@
import type { Usage } from "@step-harness/providers/compat";
+import { isImportProvenance } from "./session-import/types.ts";
import type { SessionEntry } from "./session-manager.ts";
export interface SessionUsage extends NormalizedUsage {
@@ -58,6 +59,7 @@ export function normalizeUsage(raw: unknown): NormalizedUsage | undefined {
export function entryUsage(value: unknown): { raw?: unknown; expected: boolean; assistant: boolean } {
if (!value || typeof value !== "object") return { expected: false, assistant: false };
const entry = value as Record;
+ if (isImportProvenance(entry.imported)) return { expected: false, assistant: false };
const message = entry.message as Record | undefined;
if (entry.type === "message" && message?.role === "assistant")
return { raw: message.usage, expected: true, assistant: true };
diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts
index f14ca3cd..2f895e54 100644
--- a/packages/coding-agent/src/index.ts
+++ b/packages/coding-agent/src/index.ts
@@ -347,6 +347,18 @@ export {
} from "./core/sdk.ts";
export { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "./core/session-cwd.ts";
export { exportSessionToJsonl } from "./core/session-export.ts";
+export type { ImportScanOptions, ImportScanResult } from "./core/session-import/service.ts";
+export { SessionImportError, SessionImportService } from "./core/session-import/service.ts";
+export type {
+ ImportCandidate,
+ ImportJob,
+ ImportPreview,
+ ImportProvenance,
+ ImportResult,
+ ImportSelection,
+ ImportSource,
+ ImportWarning,
+} from "./core/session-import/types.ts";
export * from "./core/session-index/index.ts";
export type {
SessionFilePersistedEvent,
diff --git a/packages/coding-agent/test/session-import-content.test.ts b/packages/coding-agent/test/session-import-content.test.ts
new file mode 100644
index 00000000..2b856f78
--- /dev/null
+++ b/packages/coding-agent/test/session-import-content.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from "vitest";
+import { stripImportedUserControls } from "../src/core/session-import/content.ts";
+import { parseForeignSession } from "../src/core/session-import/parsers.ts";
+import { sessionEntryToContextMessages } from "../src/core/session-manager.ts";
+
+const wrapped = "你好 ";
+const jsonl = (rows: unknown[]) => rows.map((row) => JSON.stringify(row)).join("\n");
+
+describe("imported user-query content", () => {
+ it("unwraps genuine content after source controls without discarding the request", () => {
+ expect(stripImportedUserControls(wrapped)).toEqual({ text: "你好", removed: false });
+ expect(stripImportedUserControls(`control ${wrapped}`)).toEqual({
+ text: "你好",
+ removed: true,
+ });
+ });
+
+ it("preserves user examples, nested literal markup, and incomplete wrappers", () => {
+ for (const value of [
+ "```xml\n你好 \n```",
+ "example: 你好 ",
+ "unfinished",
+ ])
+ expect(stripImportedUserControls(value).text).toBe(value);
+ expect(stripImportedUserControls("outer inner ").text).toBe(
+ "outer inner ",
+ );
+ });
+
+ it.each(["codex", "claude-code", "workbuddy", "qoder"] as const)(
+ "cleans %s parsing and old imported continuation context",
+ (source) => {
+ const records =
+ source === "codex"
+ ? [
+ { type: "session_meta", payload: { id: "s", cwd: "/project" } },
+ {
+ type: "response_item",
+ payload: { type: "message", role: "user", content: [{ type: "input_text", text: wrapped }] },
+ },
+ ]
+ : source === "workbuddy"
+ ? [
+ { type: "session-meta", sessionId: "s" },
+ {
+ type: "message",
+ id: "u",
+ sessionId: "s",
+ cwd: "/project",
+ role: "user",
+ content: [{ type: "input_text", text: wrapped }],
+ },
+ ]
+ : [
+ {
+ type: "user",
+ uuid: "u",
+ sessionId: "s",
+ cwd: "/project",
+ message: { role: "user", content: wrapped },
+ },
+ ];
+ const parsed = parseForeignSession(source, jsonl(records));
+ expect(parsed.title).toBe("你好");
+ const user = parsed.entries.find((entry) => entry.type === "message" && entry.message.role === "user");
+ if (!user || user.type !== "message" || user.message.role !== "user") throw new Error("User entry missing");
+ expect(user.message.content).toEqual([{ type: "text", text: "你好" }]);
+ const legacy = { ...user, message: { ...user.message, content: wrapped } };
+ const original = JSON.stringify(legacy);
+ expect(sessionEntryToContextMessages(legacy)[0]).toMatchObject({ role: "user", content: "你好" });
+ expect(JSON.stringify(legacy)).toBe(original);
+ },
+ );
+});
diff --git a/packages/coding-agent/test/session-import.test.ts b/packages/coding-agent/test/session-import.test.ts
new file mode 100644
index 00000000..ea7a56f0
--- /dev/null
+++ b/packages/coding-agent/test/session-import.test.ts
@@ -0,0 +1,1213 @@
+import { createHash } from "node:crypto";
+import { mkdir, mkdtemp, readdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { fauxAssistantMessage, fauxToolCall } from "@step-harness/providers";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { parseForeignSession } from "../src/core/session-import/parsers.ts";
+import { SessionImportService } from "../src/core/session-import/service.ts";
+import { buildSessionContext, SessionManager } from "../src/core/session-manager.ts";
+import { createWriteTool } from "../src/core/tools/write.ts";
+import { collectSessionUsage } from "../src/core/usage-totals.ts";
+import { createHarness } from "./suite/harness.ts";
+
+const timestamp = "2026-10-22T00:00:00.000Z";
+const jsonl = (records: unknown[]) => `${records.map((r) => JSON.stringify(r)).join("\n")}\n`;
+const meta = (cwd = "/project", id = "source-1") => ({
+ type: "session_meta",
+ timestamp,
+ payload: { id, cwd, cli_version: "0.163.0-alpha.11", history_mode: "paginated" },
+});
+const item = (payload: unknown) => ({ type: "response_item", timestamp, payload });
+const user = (value: string, id?: string) =>
+ item({ type: "message", role: "user", id, content: [{ type: "input_text", text: value }] });
+const reply = (value: string) =>
+ item({ type: "message", role: "assistant", content: [{ type: "output_text", text: value }] });
+
+const directories: string[] = [];
+afterEach(async () => {
+ for (const dir of directories.splice(0)) await rm(dir, { recursive: true, force: true });
+});
+
+async function fixture() {
+ const root = await mkdtemp(join(tmpdir(), "step-session-import-"));
+ directories.push(root);
+ const cwd = join(root, "project");
+ const codexRoot = join(root, "codex");
+ const claudeRoot = join(root, "claude");
+ const workbuddyRoot = join(root, "workbuddy");
+ const qoderRoot = join(root, "qoder");
+ const agentDir = join(root, "target", "agent");
+ await mkdir(cwd, { recursive: true });
+ await mkdir(join(codexRoot, "sessions"), { recursive: true });
+ await mkdir(claudeRoot);
+ await mkdir(workbuddyRoot);
+ await mkdir(qoderRoot);
+ const source = join(codexRoot, "sessions", "rollout-one.jsonl");
+ await writeFile(source, jsonl([meta(cwd), user("Keep migration facts"), reply("Confirmed")]));
+ const events: string[] = [];
+ const service = new SessionImportService({ agentDir, onPersisted: (event) => events.push(event.path) });
+ return { root, cwd, codexRoot, claudeRoot, workbuddyRoot, qoderRoot, agentDir, source, service, events };
+}
+
+async function finish(service: SessionImportService, id: string) {
+ for (let i = 0; i < 100; i++) {
+ const job = await service.getJob(id);
+ if (job.status !== "running") return job;
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+ throw new Error("Import did not settle");
+}
+
+describe("foreign adapters", () => {
+ it("strips source control envelopes from user text and keeps thinking blocks typed per platform", () => {
+ const control = "/exit exit ";
+ const codex = parseForeignSession(
+ "codex",
+ jsonl([
+ meta(),
+ user(`home `),
+ user(`${control}\n真实问题`),
+ item({ type: "reasoning", summary: [{ type: "summary_text", text: "think codex" }] }),
+ ]),
+ );
+ const codexUsers = codex.entries.filter((e) => e.type === "message" && e.message.role === "user");
+ expect(codexUsers).toHaveLength(1);
+ expect(JSON.stringify(codexUsers)).toContain("真实问题");
+ expect(JSON.stringify(codexUsers)).not.toContain("command-name");
+ expect(codex.warnings.some((w) => w.code === "source_instructions_excluded")).toBe(true);
+ const claude = parseForeignSession(
+ "claude-code",
+ jsonl([
+ { type: "user", uuid: "u", sessionId: "c", cwd: "/p", message: { content: `${control}` } },
+ { type: "user", uuid: "u2", parentUuid: "u", sessionId: "c", message: { content: "Claude real ask" } },
+ {
+ type: "assistant",
+ uuid: "a",
+ parentUuid: "u2",
+ sessionId: "c",
+ message: {
+ content: [
+ { type: "thinking", thinking: "think claude" },
+ { type: "text", text: "answer" },
+ ],
+ },
+ },
+ ]),
+ );
+ const wb = parseForeignSession(
+ "workbuddy",
+ jsonl([
+ { type: "session-meta", sessionId: "wb" },
+ {
+ type: "message",
+ id: "w1",
+ sessionId: "wb",
+ cwd: "/p",
+ role: "user",
+ content: [{ type: "input_text", text: `${control}\nWB ask` }],
+ },
+ { type: "reasoning", id: "w2", sessionId: "wb", content: [{ type: "text", text: "think wb" }] },
+ ]),
+ );
+ const qoder = parseForeignSession(
+ "qoder",
+ jsonl([
+ {
+ type: "user",
+ uuid: "q1",
+ parentUuid: null,
+ sessionId: "q",
+ cwd: "/p",
+ message: { content: "Qoder ask" },
+ },
+ {
+ type: "assistant",
+ uuid: "q2",
+ parentUuid: "q1",
+ sessionId: "q",
+ message: { content: [{ type: "thinking", thinking: "think qoder" }] },
+ },
+ { type: "active-leaf", sessionId: "q", leafUuid: "q2" },
+ ]),
+ );
+ for (const [parsed, thinking] of [
+ [codex, "think codex"],
+ [claude, "think claude"],
+ [wb, "think wb"],
+ [qoder, "think qoder"],
+ ] as const) {
+ const blocks = parsed.entries.flatMap((e) =>
+ e.type === "message" && e.message.role === "assistant" ? e.message.content : [],
+ );
+ expect(blocks.filter((b) => b.type === "thinking").map((b) => b.type === "thinking" && b.thinking)).toContain(
+ thinking,
+ );
+ }
+ expect(JSON.stringify([claude.entries, wb.entries])).not.toContain("command-name");
+ });
+ it("parses WorkBuddy messages and tools without activating source controls", () => {
+ const parsed = parseForeignSession(
+ "workbuddy",
+ jsonl([
+ { type: "session-meta", sessionId: "wb" },
+ {
+ type: "message",
+ id: "u",
+ sessionId: "wb",
+ cwd: "/project",
+ role: "user",
+ timestamp: 1792990000000,
+ content: [{ type: "input_text", text: "WorkBuddy task" }],
+ },
+ { type: "ai-title", sessionId: "wb", aiTitle: "Named WorkBuddy task" },
+ { type: "message", id: "s", role: "system", content: [{ type: "input_text", text: "SOURCE CONTROL" }] },
+ {
+ type: "function_call",
+ id: "c",
+ callId: "call",
+ name: "write_file",
+ arguments: '{"path":"never-write.txt"}',
+ },
+ {
+ type: "function_call_result",
+ id: "r",
+ callId: "call",
+ status: "failed",
+ output: { type: "text", text: "Permission denied" },
+ },
+ ]),
+ );
+ expect(parsed.title).toBe("Named WorkBuddy task");
+ expect(parsed.cwd).toBe("/project");
+ expect(parsed.entries.every((entry) => entry.imported?.source === "workbuddy")).toBe(true);
+ expect(JSON.stringify(parsed.entries)).not.toContain("SOURCE CONTROL");
+ const output = parsed.entries.find((entry) => entry.type === "message" && entry.message.role === "toolResult");
+ expect(output?.type === "message" && output.message.role === "toolResult" && output.message.isError).toBe(true);
+ });
+
+ it("uses Qoder active-leaf rather than the last stored branch", () => {
+ const parsed = parseForeignSession(
+ "qoder",
+ jsonl([
+ { type: "workspace-directories", sessionId: "qoder" },
+ { type: "runtime-config", sessionId: "qoder", model: "never-select" },
+ {
+ type: "user",
+ sessionId: "qoder",
+ cwd: "/project",
+ uuid: "u",
+ parentUuid: null,
+ message: { role: "user", content: "Qoder task" },
+ },
+ {
+ type: "assistant",
+ uuid: "kept",
+ parentUuid: "u",
+ message: { role: "assistant", content: [{ type: "text", text: "Current branch" }] },
+ },
+ {
+ type: "assistant",
+ uuid: "other",
+ parentUuid: "u",
+ message: { role: "assistant", content: [{ type: "text", text: "Unselected branch" }] },
+ },
+ { type: "active-leaf", sessionId: "qoder", leafUuid: "kept" },
+ ]),
+ );
+ expect(parsed.cwd).toBe("/project");
+ expect(JSON.stringify(parsed.entries)).toContain("Current branch");
+ expect(JSON.stringify(parsed.entries)).not.toContain("Unselected branch");
+ expect(parsed.entries.every((entry) => entry.imported?.source === "qoder")).toBe(true);
+ });
+ it("deduplicates record identities, not legitimate repeated text or event mirrors", () => {
+ const parsed = parseForeignSession(
+ "codex",
+ jsonl([
+ meta(),
+ { type: "event_msg", timestamp, payload: { type: "user_message", message: "again" } },
+ user("again", "one"),
+ reply("yes"),
+ user("again", "two"),
+ user("again", "two"),
+ ]),
+ );
+ expect(parsed.entries.filter((e) => e.type === "message" && e.message.role === "user")).toHaveLength(2);
+ });
+
+ it("deduplicates tool protocol identities even when source item IDs are absent", () => {
+ const call = item({ type: "function_call", call_id: "stable-call", name: "Read", arguments: "{}" });
+ const output = item({ type: "function_call_output", call_id: "stable-call", output: "evidence" });
+ const parsed = parseForeignSession("codex", jsonl([meta(), user("inspect"), call, output, call, output]));
+ expect(parsed.entries.filter((e) => e.type === "message" && e.message.role === "assistant")).toHaveLength(1);
+ expect(parsed.entries.filter((e) => e.type === "message" && e.message.role === "toolResult")).toHaveLength(1);
+ });
+
+ it("keeps independent tool calls and results but never restores their execution protocol or usage", () => {
+ const parsed = parseForeignSession(
+ "codex",
+ jsonl([
+ meta(),
+ user("inspect"),
+ item({ type: "function_call", call_id: "call-1", name: "exec_command", arguments: '{"cmd":"dir"}' }),
+ item({
+ type: "function_call_output",
+ call_id: "call-1",
+ output: [
+ { type: "text", text: "recorded output" },
+ { type: "input_image", image_url: "data:image/png;base64,aW1hZ2U=" },
+ ],
+ }),
+ item({ type: "message", role: "developer", content: [{ type: "input_text", text: "old controls" }] }),
+ ]),
+ );
+ const context = buildSessionContext(parsed.entries);
+ expect(context.model).toBeNull();
+ expect(JSON.stringify(context.messages)).toContain("Historical tool call");
+ expect(JSON.stringify(context.messages)).toContain("recorded output");
+ expect(JSON.stringify(context.messages)).toContain('"type":"image"');
+ expect(JSON.stringify(context.messages)).not.toContain('"type":"toolCall"');
+ expect(JSON.stringify(context.messages)).not.toContain("old controls");
+ expect(collectSessionUsage(parsed.entries)).toMatchObject({ total: 0, missing: 0, invalid: 0 });
+ });
+
+ it("retains readable pre-compaction history and uses the source summary for context", () => {
+ const parsed = parseForeignSession(
+ "codex",
+ jsonl([
+ meta(),
+ user("old detail"),
+ reply("old reply"),
+ {
+ type: "compacted",
+ timestamp,
+ payload: { message: "Carry forward the task facts", replacement_history: [] },
+ },
+ user("continue"),
+ ]),
+ );
+ expect(JSON.stringify(parsed.entries)).toContain("old detail");
+ const context = JSON.stringify(buildSessionContext(parsed.entries).messages);
+ expect(context).toContain("Carry forward the task facts");
+ expect(context).toContain("continue");
+ expect(context).not.toContain("old detail");
+ });
+
+ it("preserves retained compaction images and their captions without activating source controls", () => {
+ const parsed = parseForeignSession(
+ "codex",
+ jsonl([
+ meta(),
+ user("old detail"),
+ {
+ type: "compacted",
+ timestamp,
+ payload: {
+ message: "Task summary",
+ replacement_history: [
+ {
+ type: "message",
+ role: "user",
+ content: [
+ { type: "input_text", text: "Use this image" },
+ { type: "input_image", image_url: "data:image/png;base64,aW1hZ2U=" },
+ ],
+ },
+ {
+ type: "message",
+ role: "developer",
+ content: [{ type: "input_text", text: "excluded developer controls" }],
+ },
+ ],
+ },
+ },
+ user("continue"),
+ ]),
+ );
+ const context = JSON.stringify(buildSessionContext(parsed.entries).messages);
+ expect(context).toContain('"type":"image"');
+ expect(context).toContain('"data":"aW1hZ2U="');
+ expect(context).toContain("Use this image");
+ expect(context).not.toContain("old detail");
+ expect(context).not.toContain("excluded developer controls");
+ expect(parsed.warnings).not.toContainEqual(expect.objectContaining({ code: "attachment_missing" }));
+ });
+
+ it("keeps the latest compaction attachments through rollback without counting them as new user turns", () => {
+ const compacted = (data: string) => ({
+ type: "compacted",
+ timestamp,
+ payload: {
+ replacement_history: [
+ {
+ type: "message",
+ role: "user",
+ content: [{ type: "input_image", image_url: `data:image/png;base64,${data}` }],
+ },
+ ],
+ },
+ });
+ const parsed = parseForeignSession(
+ "codex",
+ jsonl([
+ meta(),
+ user("old detail"),
+ compacted("b2xk"),
+ compacted("bmV3"),
+ user("rolled back turn"),
+ { type: "event_msg", timestamp, payload: { type: "thread_rolled_back", num_turns: 1 } },
+ ]),
+ );
+ const context = JSON.stringify(buildSessionContext(parsed.entries).messages);
+ expect(context).toContain('"data":"bmV3"');
+ expect(context).not.toContain('"data":"b2xk"');
+ expect(context).not.toContain("rolled back turn");
+ expect(context).not.toContain("old detail");
+ });
+
+ it.each([
+ [true, "failed"],
+ [false, "completed"],
+ [undefined, "unknown"],
+ ] as const)("preserves Claude tool outcome %s in continuation context as %s", (isError, status) => {
+ const parsed = parseForeignSession(
+ "claude-code",
+ jsonl([
+ {
+ type: "user",
+ uuid: "u",
+ sessionId: "cc",
+ message: { content: "run" },
+ },
+ {
+ type: "assistant",
+ uuid: "a",
+ parentUuid: "u",
+ sessionId: "cc",
+ message: { content: [{ type: "tool_use", id: "t", name: "Write", input: { path: "x" } }] },
+ },
+ {
+ type: "user",
+ uuid: "r",
+ parentUuid: "a",
+ sessionId: "cc",
+ message: {
+ content: [{ type: "tool_result", tool_use_id: "t", content: "same output", is_error: isError }],
+ },
+ },
+ ]),
+ );
+ const context = JSON.stringify(buildSessionContext(parsed.entries).messages);
+ expect(context).toContain(`status: ${status}`);
+ expect(context).toContain("same output");
+ expect(context).not.toContain('"type":"toolCall"');
+ });
+
+ it("keeps Codex tool outcomes unknown when no explicit outcome is recorded", () => {
+ const parsed = parseForeignSession(
+ "codex",
+ jsonl([
+ meta(),
+ user("run"),
+ item({ type: "function_call", call_id: "t", name: "Write", arguments: "{}" }),
+ item({ type: "function_call_output", call_id: "t", output: "done" }),
+ ]),
+ );
+ expect(JSON.stringify(buildSessionContext(parsed.entries).messages)).toContain("status: unknown");
+ });
+
+ it("applies rollback and rejects malformed or unknown record formats", () => {
+ const parsed = parseForeignSession(
+ "codex",
+ jsonl([
+ meta(),
+ user("keep"),
+ reply("kept"),
+ user("remove"),
+ reply("removed"),
+ { type: "event_msg", timestamp, payload: { type: "thread_rolled_back", num_turns: 1 } },
+ ]),
+ );
+ expect(JSON.stringify(parsed.entries)).not.toContain('"remove"');
+ expect(() => parseForeignSession("codex", `${jsonl([meta()])}{`)).toThrow("Invalid JSONL");
+ expect(() => parseForeignSession("codex", jsonl([meta(), user("x"), { type: "future_record" }]))).toThrow(
+ "Unsupported",
+ );
+ });
+
+ it("reads the selected Claude branch and does not mistake tool results for human instructions", () => {
+ const parsed = parseForeignSession(
+ "claude-code",
+ jsonl([
+ {
+ type: "user",
+ uuid: "u",
+ parentUuid: null,
+ sessionId: "cc",
+ cwd: "/project",
+ timestamp,
+ message: { content: "inspect" },
+ },
+ {
+ type: "assistant",
+ uuid: "wrong",
+ parentUuid: "u",
+ sessionId: "cc",
+ timestamp,
+ message: { content: [{ type: "text", text: "other branch" }] },
+ },
+ {
+ type: "assistant",
+ uuid: "a",
+ parentUuid: "u",
+ sessionId: "cc",
+ timestamp,
+ message: { content: [{ type: "tool_use", id: "t", name: "Read", input: { file_path: "a.ts" } }] },
+ },
+ {
+ type: "user",
+ uuid: "r",
+ parentUuid: "a",
+ sessionId: "cc",
+ timestamp,
+ message: {
+ content: [{ type: "tool_result", tool_use_id: "t", content: "file contents", is_error: false }],
+ },
+ },
+ ]),
+ );
+ expect(parsed.entries.filter((e) => e.type === "message" && e.message.role === "user")).toHaveLength(1);
+ expect(JSON.stringify(parsed.entries)).not.toContain("other branch");
+ expect(JSON.stringify(buildSessionContext(parsed.entries).messages)).toContain("file contents");
+ });
+
+ it("warns when a Claude compaction boundary prevents lossless history migration", () => {
+ const parsed = parseForeignSession(
+ "claude-code",
+ jsonl([
+ { type: "system", subtype: "compact_boundary", sessionId: "cc", timestamp },
+ {
+ type: "user",
+ uuid: "summary",
+ parentUuid: null,
+ sessionId: "cc",
+ timestamp,
+ isCompactSummary: true,
+ message: { content: "Carry forward the previous task facts" },
+ },
+ ]),
+ );
+ expect(parsed.warnings).toContainEqual({ code: "claude_compaction", count: 2 });
+ expect(JSON.stringify(buildSessionContext(parsed.entries).messages)).toContain("previous task facts");
+ });
+});
+
+describe("offline import lifecycle", () => {
+ it("discovers four mixed source formats once and imports new sources with durable provenance", async () => {
+ const f = await fixture();
+ try {
+ await writeFile(
+ join(f.claudeRoot, "claude.jsonl"),
+ jsonl([{ type: "user", sessionId: "claude", cwd: f.cwd, uuid: "cu", message: { content: "Claude task" } }]),
+ );
+ await writeFile(
+ join(f.workbuddyRoot, "wb.jsonl"),
+ jsonl([
+ { type: "session-meta", sessionId: "wb" },
+ {
+ type: "message",
+ id: "wu",
+ sessionId: "wb",
+ cwd: f.cwd,
+ role: "user",
+ content: [{ type: "input_text", text: "WorkBuddy task" }],
+ },
+ { type: "ai-title", sessionId: "wb", aiTitle: "Named WorkBuddy task" },
+ ]),
+ );
+ await writeFile(
+ join(f.qoderRoot, "qoder.jsonl"),
+ jsonl([
+ { type: "workspace-directories", sessionId: "qoder" },
+ { type: "user", sessionId: "qoder", cwd: f.cwd, uuid: "qu", message: { content: "Qoder task" } },
+ ]),
+ );
+ const scan = await f.service.scan({ customRoot: f.root });
+ expect(scan.errors).toEqual([]);
+ expect(scan.candidates.map((c) => c.source).sort()).toEqual(["claude-code", "codex", "qoder", "workbuddy"]);
+ for (const source of ["workbuddy", "qoder"] as const) {
+ const candidate = scan.candidates.find((c) => c.source === source);
+ expect(candidate?.cwd).toBe(f.cwd);
+ const preview = await f.service.preview(candidate?.id ?? "");
+ const job = await finish(
+ f.service,
+ (await f.service.start([{ candidateId: preview.id, fingerprint: preview.fingerprint, cwd: f.cwd }])).id,
+ );
+ expect(job.results[0].status).toBe("imported");
+ expect(job.results[0].source).toBe(source);
+ expect(JSON.stringify(SessionManager.open(job.results[0].path as string).getEntries())).toContain(source);
+ }
+ } finally {
+ await f.service.close();
+ }
+ });
+ it("appends custom scans without invalidating existing reviewed candidates and deduplicates repeated paths", async () => {
+ const f = await fixture();
+ try {
+ const original = (await f.service.scan(f)).candidates[0];
+ const reviewed = await f.service.preview(original.id);
+ const customRoot = join(f.root, "extra");
+ await mkdir(customRoot);
+ await writeFile(join(customRoot, "rollout-extra.jsonl"), jsonl([meta(f.cwd, "extra"), user("Extra history")]));
+ const extra = await f.service.scan({ customRoot });
+ expect(extra.errors).toEqual([]);
+ expect(extra.candidates).toHaveLength(1);
+ expect(extra.candidates[0].sessionId).toBe("extra");
+ const repeated = await f.service.scan({ customRoot });
+ expect(repeated.candidates).toHaveLength(0);
+ expect(repeated.existingCount).toBe(1);
+ expect((await f.service.preview(original.id)).sessionId).toBe(original.sessionId);
+ const imported = await finish(
+ f.service,
+ (await f.service.start([{ candidateId: original.id, fingerprint: reviewed.fingerprint, cwd: f.cwd }])).id,
+ );
+ expect(imported.results[0].status).toBe("imported");
+ const missing = await f.service.scan({ customRoot: join(f.root, "missing") });
+ expect(missing.errors.length).toBeGreaterThan(0);
+ expect((await f.service.preview(extra.candidates[0].id)).sessionId).toBe("extra");
+ } finally {
+ await f.service.close();
+ }
+ });
+ it("returns readable scan titles without previewing or validating the full transcript", async () => {
+ const f = await fixture();
+ const claude = join(f.claudeRoot, "named.jsonl");
+ await writeFile(
+ claude,
+ jsonl([
+ {
+ type: "user",
+ sessionId: "named",
+ cwd: f.cwd,
+ message: { content: [{ type: "text", text: "First task" }] },
+ },
+ { type: "summary", sessionId: "named", summary: "Useful summary" },
+ { type: "custom-title", sessionId: "named", customTitle: "Explicit name" },
+ ]) + "malformed unfinished tail",
+ );
+ try {
+ const scan = await f.service.scan(f);
+ expect(scan.errors).toEqual([]);
+ expect(scan.candidates.find((c) => c.source === "codex")?.title).toBe("Keep migration facts");
+ expect(scan.candidates.find((c) => c.sessionId === "named")?.title).toBe("Explicit name");
+ expect(await readdir(f.agentDir).catch(() => [])).toEqual([]);
+ expect(await readFile(claude, "utf8")).toContain("malformed unfinished tail");
+ } finally {
+ await f.service.close();
+ }
+ });
+
+ it("bounds title discovery but lets preview replace first-user fallbacks with parsed titles", async () => {
+ const f = await fixture();
+ await writeFile(
+ join(f.claudeRoot, "late.jsonl"),
+ jsonl([
+ { type: "user", uuid: "u", sessionId: "late", cwd: f.cwd, message: { content: "Early task" } },
+ { type: "ignored", padding: "x".repeat(1024 * 1024) },
+ { type: "custom-title", sessionId: "late", customTitle: "Late explicit title" },
+ ]),
+ );
+ try {
+ const c = (await f.service.scan(f)).candidates.find((c) => c.sessionId === "late");
+ expect(c?.title).toBe("Early task");
+ expect((await f.service.preview(c?.id ?? "")).title).toBe("Late explicit title");
+ } finally {
+ await f.service.close();
+ }
+ });
+
+ it("prefers reliable Codex index and metadata titles over first user text", async () => {
+ const f = await fixture();
+ await writeFile(
+ f.source,
+ jsonl([{ ...meta(f.cwd), payload: { ...meta(f.cwd).payload, title: "Metadata title" } }, user("Task text")]),
+ );
+ try {
+ let c = (await f.service.scan(f)).candidates[0];
+ expect(c.title).toBe("Metadata title");
+ expect((await f.service.preview(c.id)).title).toBe("Metadata title");
+ await writeFile(
+ join(f.codexRoot, "session_index.jsonl"),
+ jsonl([{ id: c.sessionId, thread_name: "Index title" }]),
+ );
+ c = (await f.service.scan(f)).candidates[0];
+ expect(c.title).toBe("Index title");
+ expect((await f.service.preview(c.id)).title).toBe("Index title");
+ } finally {
+ await f.service.close();
+ }
+ });
+ it("records selected destination cwd for imported, skipped and failed outcomes", async () => {
+ const f = await fixture();
+ const destination = join(f.root, "destination");
+ await mkdir(destination);
+ try {
+ const c = (await f.service.scan(f)).candidates[0];
+ const p = await f.service.preview(c.id);
+ const selection = { candidateId: c.id, fingerprint: p.fingerprint, cwd: destination };
+ for (const status of ["imported", "skipped"] as const) {
+ const job = await finish(f.service, (await f.service.start([selection])).id);
+ expect(job.results[0]).toMatchObject({ status, cwd: destination });
+ }
+ const missing = join(f.root, "missing");
+ const failed = await finish(f.service, (await f.service.start([{ ...selection, cwd: missing }])).id);
+ expect(failed.results[0]).toMatchObject({ status: "failed", cwd: missing });
+ await f.service.close();
+ const restarted = new SessionImportService({ agentDir: f.agentDir });
+ try {
+ expect((await restarted.getJob(failed.id)).results[0].cwd).toBe(missing);
+ } finally {
+ await restarted.close();
+ }
+ } finally {
+ await f.service.close();
+ }
+ });
+
+ it("recovers older result cwd, title and provenance only from a contained bounded native header", async () => {
+ const f = await fixture();
+ const root = join(f.agentDir, "sessions", "imported");
+ await mkdir(root, { recursive: true });
+ const native = join(root, "native.jsonl");
+ const outside = join(f.root, "outside.jsonl");
+ const header = jsonl([
+ { type: "session", cwd: f.cwd },
+ {
+ type: "message",
+ message: { role: "user", content: "First native task" },
+ imported: { source: "codex", sessionId: "s", itemId: "u" },
+ },
+ { type: "session_info", name: "[codex] Saved name" },
+ ]);
+ await writeFile(native, header);
+ await writeFile(outside, header);
+ const linkPath = join(root, "escape.jsonl");
+ await symlink(outside, linkPath);
+ const bounded = join(root, "bounded.jsonl");
+ await writeFile(
+ bounded,
+ jsonl([
+ { type: "session", cwd: f.cwd },
+ { type: "ignored", padding: "x".repeat(1024 * 1024) },
+ { type: "session_info", name: "Not read" },
+ ]),
+ );
+ const id = "11111111-1111-4111-a111-111111111111";
+ const journal = join(f.agentDir, "session-imports", `${id}.json`);
+ await mkdir(join(f.agentDir, "session-imports"));
+ const job = {
+ id,
+ createdAt: timestamp,
+ status: "completed",
+ total: 6,
+ results: [
+ { candidateId: "old", status: "imported", path: native },
+ {
+ candidateId: "source-known",
+ status: "skipped",
+ path: native,
+ source: "claude-code",
+ title: "Existing title",
+ },
+ { candidateId: "outside", status: "imported", path: outside },
+ { candidateId: "symlink", status: "imported", path: linkPath },
+ { candidateId: "bounded", status: "imported", path: bounded },
+ { candidateId: "deleted", status: "imported", path: join(root, "missing.jsonl") },
+ ],
+ };
+ await writeFile(journal, JSON.stringify(job));
+ try {
+ const results = (await f.service.getJob(id)).results;
+ expect(results[0]).toMatchObject({ cwd: f.cwd, title: "Saved name", source: "codex" });
+ expect(results[1]).toMatchObject({ cwd: f.cwd, title: "Existing title", source: "claude-code" });
+ for (const index of [2, 3, 5]) {
+ expect(results[index].cwd).toBeUndefined();
+ expect(results[index].title).toBeUndefined();
+ expect(results[index].source).toBeUndefined();
+ }
+ expect(results[4]).toMatchObject({ cwd: f.cwd });
+ expect(results[4].title).toBeUndefined();
+ expect(results[4].source).toBeUndefined();
+ await writeFile(native, header.split("\n").slice(0, 2).join("\n"));
+ expect((await f.service.getJob(id)).results[0].title).toBe("First native task");
+ for (const cwd of [42, "relative", "", null, "/bad\0path"]) {
+ await writeFile(journal, JSON.stringify({ ...job, results: [{ ...job.results[0], cwd }] }));
+ await expect(f.service.getJob(id)).rejects.toThrow("Invalid persisted import result");
+ }
+ } finally {
+ await f.service.close();
+ }
+ });
+
+ it.each([5000, 1024 * 1024])(
+ "discovers and imports Claude metadata in a large first record with %s extra bytes",
+ async (extra) => {
+ const f = await fixture();
+ const first = {
+ type: "user",
+ uuid: "long-user",
+ sessionId: "long-claude",
+ cwd: f.cwd,
+ timestamp,
+ message: { content: "" },
+ };
+ const emptyRecord = JSON.stringify(first);
+ const contentStart = emptyRecord.indexOf('"content":""') + '"content":"'.length;
+ const prefixBytes = Buffer.byteLength(emptyRecord.slice(0, contentStart));
+ first.message.content = `${"x".repeat(65535 - prefixBytes)}\u754c${"x".repeat(extra)}`;
+ const input = jsonl([first]);
+ // Include a record without a trailing newline; split UTF-8 must not be decoded per chunk.
+ await writeFile(join(f.claudeRoot, "long.jsonl"), input.trimEnd());
+ try {
+ const scan = await f.service.scan(f);
+ expect(scan.errors).toEqual([]);
+ const c = scan.candidates.find((candidate) => candidate.sessionId === "long-claude");
+ expect(c).toBeDefined();
+ const p = await f.service.preview(c?.id ?? "");
+ const job = await finish(
+ f.service,
+ (await f.service.start([{ candidateId: p.id, fingerprint: p.fingerprint, cwd: f.cwd }])).id,
+ );
+ expect(job.results[0].status).toBe("imported");
+ const messages = SessionManager.open(job.results[0].path as string).buildSessionContext().messages;
+ expect(JSON.stringify(messages)).toContain("\u754c");
+ expect(JSON.stringify(messages)).not.toContain("\uFFFD");
+ } finally {
+ await f.service.close();
+ }
+ },
+ );
+
+ it("scans a selected date subtree but excludes archive subtrees even when selected directly", async () => {
+ const f = await fixture();
+ const dateRoot = join(f.codexRoot, "sessions", "2026");
+ const month = join(dateRoot, "10");
+ const archivedRoot = join(f.codexRoot, "archived_sessions", "2026");
+ await mkdir(month, { recursive: true });
+ await mkdir(archivedRoot, { recursive: true });
+ await writeFile(join(month, "rollout-day.jsonl"), jsonl([meta(f.cwd), user("dated")]));
+ await writeFile(join(archivedRoot, "rollout-archive.jsonl"), "{ malformed archive");
+ try {
+ expect((await f.service.scan({ ...f, codexRoot: dateRoot })).candidates).toHaveLength(1);
+ const scan = await f.service.scan({ ...f, codexRoot: archivedRoot });
+ expect(scan).toEqual({
+ existingCount: 0,
+ roots: {
+ codex: await realpath(archivedRoot),
+ "claude-code": await realpath(f.claudeRoot),
+ workbuddy: await realpath(f.workbuddyRoot),
+ qoder: await realpath(f.qoderRoot),
+ },
+ candidates: [],
+ errors: [],
+ });
+ } finally {
+ await f.service.close();
+ }
+ });
+
+ it("owns submitted selections and source references across later scans", async () => {
+ const f = await fixture();
+ try {
+ const candidate = (await f.service.scan(f)).candidates[0];
+ const p = await f.service.preview(candidate.id);
+ const selections = [{ candidateId: candidate.id, fingerprint: p.fingerprint, cwd: f.cwd }];
+ const pending = f.service.start(selections);
+ selections[0].candidateId = "caller-mutated";
+ selections[0].cwd = join(f.root, "missing");
+ await f.service.scan({ ...f, codexRoot: f.claudeRoot });
+ const job = await finish(f.service, (await pending).id);
+ expect(job.results[0]).toMatchObject({ candidateId: candidate.id, status: "imported" });
+ } finally {
+ await f.service.close();
+ }
+ });
+
+ it("excludes archives before parsing them and offers an explicit opt-in", async () => {
+ const f = await fixture();
+ await mkdir(join(f.codexRoot, "archived_sessions"));
+ await writeFile(
+ join(f.codexRoot, "archived_sessions", "rollout-archive.jsonl"),
+ jsonl([meta(f.cwd, "archived"), user("old")]),
+ );
+ try {
+ const scan = await f.service.scan(f);
+ expect(scan.candidates).toHaveLength(1);
+ const all = await f.service.scan({ ...f, includeArchived: true });
+ expect(all.candidates).toHaveLength(2);
+ expect(all.candidates.find((c) => c.sessionId === "archived")?.archived).toBe(true);
+ } finally {
+ await f.service.close();
+ }
+ });
+
+ it("atomically publishes native sessions, skips repeated snapshots, preserves new versions and leaves sources untouched", async () => {
+ const f = await fixture();
+ const original = await readFile(f.source);
+ try {
+ const candidate = (await f.service.scan(f)).candidates[0];
+ const p = await f.service.preview(candidate.id);
+ const selection = { candidateId: candidate.id, fingerprint: p.fingerprint, cwd: f.cwd };
+ const first = await finish(f.service, (await f.service.start([selection])).id);
+ expect(first.results[0].status).toBe("imported");
+ const path = first.results[0].path;
+ expect(path).toBeTruthy();
+ const session = SessionManager.open(path as string);
+ expect(JSON.stringify(session.buildSessionContext().messages)).toContain("migration facts");
+ session.appendMessage({ role: "user", content: "Continue in SCD", timestamp: Date.now() });
+ const second = await finish(f.service, (await f.service.start([selection])).id);
+ expect(second.results[0].status).toBe("skipped");
+ expect(await readFile(path as string, "utf8")).toContain("Continue in SCD");
+ expect(await readFile(f.source)).toEqual(original);
+ await writeFile(f.source, `${original.toString("utf8")}${jsonl([user("new source turn")])}`);
+ const stale = await finish(f.service, (await f.service.start([selection])).id);
+ expect(stale.results[0].status).toBe("skipped");
+ const updated = await f.service.preview(candidate.id);
+ const third = await finish(
+ f.service,
+ (await f.service.start([{ ...selection, fingerprint: updated.fingerprint }])).id,
+ );
+ expect(third.results[0].sessionId).not.toBe(first.results[0].sessionId);
+ expect(f.events).toHaveLength(4);
+ expect(await readdir(join(f.agentDir, "sessions", "imported"))).toHaveLength(2);
+ } finally {
+ await f.service.close();
+ }
+ });
+
+ it("imports the reviewed snapshot even when an active source appends metadata and messages", async () => {
+ const f = await fixture();
+ try {
+ const c = (await f.service.scan(f)).candidates[0];
+ const p = await f.service.preview(c.id);
+ await writeFile(
+ f.source,
+ `${await readFile(f.source, "utf8")}${jsonl([
+ { type: "event_msg", timestamp, payload: { type: "token_count", info: {} } },
+ user("not reviewed"),
+ ])}`,
+ );
+ const job = await finish(
+ f.service,
+ (await f.service.start([{ candidateId: c.id, fingerprint: p.fingerprint, cwd: f.cwd }])).id,
+ );
+ expect(job.results[0].status).toBe("imported");
+ const saved = await readFile(job.results[0].path as string, "utf8");
+ expect(saved).toContain("Keep migration facts");
+ expect(saved).not.toContain("not reviewed");
+ expect(saved).toContain(p.fingerprint);
+ } finally {
+ await f.service.close();
+ }
+ });
+
+ it("serializes concurrent duplicate jobs and restores persisted results after restart", async () => {
+ const f = await fixture();
+ const candidate = (await f.service.scan(f)).candidates[0];
+ const p = await f.service.preview(candidate.id);
+ const selection = { candidateId: candidate.id, fingerprint: p.fingerprint, cwd: f.cwd };
+ const jobs = await Promise.all([f.service.start([selection]), f.service.start([selection])]);
+ await Promise.all(jobs.map((j) => finish(f.service, j.id)));
+ await f.service.close();
+ const results = await Promise.all(jobs.map((j) => f.service.getJob(j.id)));
+ expect(results.flatMap((j) => j.results.map((r) => r.status)).sort()).toEqual(["imported", "skipped"]);
+ const restarted = new SessionImportService({ agentDir: f.agentDir });
+ try {
+ expect(await restarted.listJobs()).toHaveLength(2);
+ await writeFile(
+ join(f.agentDir, "session-imports", `${jobs[0].id}.json`),
+ JSON.stringify({ ...jobs[0], status: "running" }),
+ );
+ expect((await restarted.getJob(jobs[0].id)).status).toBe("interrupted");
+ } finally {
+ await restarted.close();
+ }
+ });
+
+ it("rejects expired or unreviewed fingerprints without creating jobs", async () => {
+ const f = await fixture();
+ try {
+ const c = (await f.service.scan(f)).candidates[0];
+ const p = await f.service.preview(c.id);
+ await expect(
+ f.service.start([{ candidateId: c.id, fingerprint: "f".repeat(64), cwd: f.cwd }]),
+ ).rejects.toThrow("Preview expired");
+ const now = Date.now();
+ const clock = vi.spyOn(Date, "now").mockReturnValue(now + 16 * 60 * 1000);
+ try {
+ await expect(
+ f.service.start([{ candidateId: c.id, fingerprint: p.fingerprint, cwd: f.cwd }]),
+ ).rejects.toThrow("Preview expired");
+ } finally {
+ clock.mockRestore();
+ }
+ expect(await f.service.listJobs()).toHaveLength(0);
+ const refreshed = await f.service.preview(c.id);
+ const job = await finish(
+ f.service,
+ (await f.service.start([{ candidateId: c.id, fingerprint: refreshed.fingerprint, cwd: f.cwd }])).id,
+ );
+ expect(job.results[0].status).toBe("imported");
+ } finally {
+ await f.service.close();
+ }
+ });
+
+ it("reports durable imports as successful even if the search-index callback fails", async () => {
+ const f = await fixture();
+ const service = new SessionImportService({
+ agentDir: f.agentDir,
+ onPersisted: () => {
+ throw new Error("index unavailable");
+ },
+ });
+ try {
+ const c = (await service.scan(f)).candidates[0];
+ const p = await service.preview(c.id);
+ const job = await finish(
+ service,
+ (await service.start([{ candidateId: c.id, fingerprint: p.fingerprint, cwd: f.cwd }])).id,
+ );
+ expect(job.results[0]).toMatchObject({ status: "imported", title: p.title });
+ expect(job.results[0].warning).toContain("index unavailable");
+ expect(SessionManager.open(job.results[0].path as string).buildSessionContext().messages).not.toHaveLength(0);
+ } finally {
+ await service.close();
+ await f.service.close();
+ }
+ });
+
+ it("validates source bounds, destination and snapshot fingerprints", async () => {
+ const f = await fixture();
+ try {
+ const c = (await f.service.scan(f)).candidates[0];
+ const p = await f.service.preview(c.id);
+ expect(p.fingerprint).toBe(
+ createHash("sha256")
+ .update(await readFile(f.source))
+ .digest("hex"),
+ );
+ await expect(f.service.start([{ candidateId: c.id, fingerprint: "bad", cwd: f.cwd }])).rejects.toThrow(
+ "Invalid selection",
+ );
+ const job = await finish(
+ f.service,
+ (await f.service.start([{ candidateId: c.id, fingerprint: p.fingerprint, cwd: join(f.root, "missing") }]))
+ .id,
+ );
+ expect(job.results[0].status).toBe("failed");
+ await expect(f.service.preview("unknown")).rejects.toThrow("expired");
+ } finally {
+ await f.service.close();
+ }
+ });
+
+ it("copies only contained image attachments and fingerprints their contents", async () => {
+ const f = await fixture();
+ const image = join(f.codexRoot, "image.png");
+ await writeFile(image, Buffer.from("89504e470d0a1a0a01020304", "hex"));
+ await writeFile(
+ f.source,
+ jsonl([
+ meta(f.cwd),
+ item({
+ type: "message",
+ role: "user",
+ content: [
+ { type: "input_text", text: "inspect image" },
+ { type: "input_image", image_url: image },
+ { type: "input_image", image_url: join(f.root, "outside.png") },
+ ],
+ }),
+ ]),
+ );
+ try {
+ const c = (await f.service.scan(f)).candidates[0];
+ const p = await f.service.preview(c.id);
+ expect(p.warnings).toContainEqual({ code: "attachment_missing", count: 1 });
+ await writeFile(image, Buffer.from("89504e470d0a1a0a05060708", "hex"));
+ const changed = await f.service.preview(c.id);
+ expect(changed.fingerprint).not.toBe(p.fingerprint);
+ const reviewed = await finish(
+ f.service,
+ (await f.service.start([{ candidateId: c.id, fingerprint: p.fingerprint, cwd: f.cwd }])).id,
+ );
+ expect(await readFile(reviewed.results[0].path as string, "utf8")).toContain(
+ Buffer.from("89504e470d0a1a0a01020304", "hex").toString("base64"),
+ );
+ const job = await finish(
+ f.service,
+ (await f.service.start([{ candidateId: c.id, fingerprint: changed.fingerprint, cwd: f.cwd }])).id,
+ );
+ expect(await readFile(job.results[0].path as string, "utf8")).toContain('"mimeType":"image/png"');
+ } finally {
+ await f.service.close();
+ }
+ });
+
+ it("reopens compacted attachments and failed tool evidence into a real continuation request", async () => {
+ const f = await fixture();
+ const image = Buffer.from("89504e470d0a1a0a01020304", "hex");
+ await writeFile(join(f.codexRoot, "retained.png"), image);
+ await writeFile(
+ f.source,
+ jsonl([
+ meta(f.cwd),
+ user("old detail"),
+ {
+ type: "compacted",
+ timestamp,
+ payload: {
+ message: "Continue inspecting the retained image",
+ replacement_history: [
+ {
+ type: "message",
+ role: "user",
+ content: [
+ { type: "input_text", text: "Retained caption" },
+ { type: "input_image", image_url: "retained.png" },
+ { type: "input_image", image_url: "missing.png" },
+ ],
+ },
+ ],
+ },
+ },
+ ]),
+ );
+ const h = await createHarness({ tools: [], settings: { compaction: { enabled: false } } });
+ try {
+ const c = (await f.service.scan(f)).candidates[0];
+ const p = await f.service.preview(c.id);
+ expect(p.warnings).toContainEqual({ code: "attachment_missing", count: 1 });
+ const job = await finish(
+ f.service,
+ (await f.service.start([{ candidateId: c.id, fingerprint: p.fingerprint, cwd: f.cwd }])).id,
+ );
+ expect(job.results[0].status).toBe("imported");
+ const reopened = SessionManager.open(job.results[0].path as string);
+ const failedTool = parseForeignSession(
+ "claude-code",
+ jsonl([
+ { type: "user", uuid: "u", sessionId: "cc", message: { content: "write" } },
+ {
+ type: "assistant",
+ uuid: "a",
+ parentUuid: "u",
+ sessionId: "cc",
+ message: { content: [{ type: "tool_use", id: "t", name: "Write", input: {} }] },
+ },
+ {
+ type: "user",
+ uuid: "r",
+ parentUuid: "a",
+ sessionId: "cc",
+ message: { content: [{ type: "tool_result", tool_use_id: "t", content: "done", is_error: true }] },
+ },
+ ]),
+ );
+ h.session.agent.state.messages = [
+ ...reopened.buildSessionContext().messages,
+ ...buildSessionContext(failedTool.entries).messages,
+ ];
+ let observed = false;
+ h.setResponses([
+ (context) => {
+ const request = JSON.stringify(context.messages);
+ expect(request).toContain('"type":"image"');
+ expect(request).toContain(image.toString("base64"));
+ expect(request).toContain("Retained caption");
+ expect(request).toContain("status: failed");
+ expect(request).not.toContain("old detail");
+ expect(request).not.toContain('"type":"toolCall"');
+ observed = true;
+ return fauxAssistantMessage("Verified retained image and failed historical tool");
+ },
+ ]);
+ await h.session.prompt("Continue from the migrated evidence.");
+ expect(observed).toBe(true);
+ } finally {
+ h.cleanup();
+ await f.service.close();
+ }
+ });
+
+ it("cancels remaining work and does not publish half-written sessions", async () => {
+ const f = await fixture();
+ try {
+ const c = (await f.service.scan(f)).candidates[0];
+ const p = await f.service.preview(c.id);
+ const selections = Array.from({ length: 100 }, () => ({
+ candidateId: c.id,
+ fingerprint: p.fingerprint,
+ cwd: f.cwd,
+ }));
+ const job = await f.service.start(selections);
+ await f.service.cancel(job.id);
+ const settled = await finish(f.service, job.id);
+ expect(settled.status).toBe("cancelled");
+ expect(settled.results.length).toBeLessThan(100);
+ } finally {
+ await f.service.close();
+ }
+ });
+
+ it("continues a reopened import through a faux provider and executes only a new native tool", async () => {
+ const f = await fixture();
+ await writeFile(
+ f.source,
+ jsonl([
+ meta(f.cwd),
+ user("Migration proof must be written only after continuation"),
+ item({
+ type: "function_call",
+ call_id: "old-tool",
+ name: "exec_command",
+ arguments: '{"cmd":"never replay"}',
+ }),
+ item({ type: "function_call_output", call_id: "old-tool", output: "historical evidence" }),
+ ]),
+ );
+ const h = await createHarness({ tools: [createWriteTool(f.cwd)], settings: { compaction: { enabled: false } } });
+ try {
+ const c = (await f.service.scan(f)).candidates[0];
+ const p = await f.service.preview(c.id);
+ const job = await finish(
+ f.service,
+ (await f.service.start([{ candidateId: c.id, fingerprint: p.fingerprint, cwd: f.cwd }])).id,
+ );
+ const loaded = SessionManager.open(job.results[0].path as string).buildSessionContext();
+ h.session.agent.state.messages = loaded.messages;
+ let observed = false;
+ h.setResponses([
+ (context) => {
+ const request = JSON.stringify(context.messages);
+ expect(request).toContain("historical evidence");
+ expect(request).toContain("Migration proof");
+ expect(request).not.toContain('"type":"toolCall"');
+ observed = true;
+ return fauxAssistantMessage(
+ fauxToolCall("write", { path: "migration-proof.txt", content: "continued safely" }),
+ { stopReason: "toolUse" },
+ );
+ },
+ fauxAssistantMessage("Continuation complete"),
+ ]);
+ await h.session.prompt("Continue and write the migration proof.");
+ expect(observed).toBe(true);
+ expect(await readFile(join(f.cwd, "migration-proof.txt"), "utf8")).toBe("continued safely");
+ } finally {
+ h.cleanup();
+ await f.service.close();
+ }
+ });
+});