From 6a0984573275d2398faa812bbf661a9dc5557e06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9F=92=E6=8B=BE=E5=85=86=E5=AD=97=E8=8A=82?= <3311482378@qq.com> Date: Thu, 22 Oct 2026 14:42:09 +0800 Subject: [PATCH] feat(web): import local Codex and Claude Code sessions --- apps/web/AGENTS.md | 5 + .../src/features/conversation/ToolCall.tsx | 2 +- .../conversation/tool-presentation.ts | 1 + .../features/conversation/turn-artifacts.ts | 2 +- .../features/settings/SessionImportPage.tsx | 406 +++++++++ .../src/features/settings/SettingsView.tsx | 4 + .../src/features/settings/session-import.css | 155 ++++ apps/web/client/src/i18n/messages.ts | 86 ++ apps/web/client/src/state/session.ts | 17 +- apps/web/client/src/state/store.ts | 2 +- apps/web/client/src/state/types.ts | 37 +- apps/web/openapi.yaml | 162 ++++ apps/web/src/runtime.ts | 6 +- apps/web/src/server.ts | 65 ++ apps/web/src/turn-changes.ts | 2 +- apps/web/test/e2e/web.e2e.mjs | 105 +++ packages/coding-agent/AGENTS.md | 7 + packages/coding-agent/docs/session-format.md | 8 + packages/coding-agent/docs/session-import.md | 96 +++ .../src/core/session-import/parsers.ts | 408 +++++++++ .../src/core/session-import/service.ts | 676 +++++++++++++++ .../src/core/session-import/types.ts | 84 ++ .../coding-agent/src/core/session-manager.ts | 58 +- .../coding-agent/src/core/usage-totals.ts | 2 + packages/coding-agent/src/index.ts | 12 + .../coding-agent/test/session-import.test.ts | 789 ++++++++++++++++++ 26 files changed, 3186 insertions(+), 11 deletions(-) create mode 100644 apps/web/client/src/features/settings/SessionImportPage.tsx create mode 100644 apps/web/client/src/features/settings/session-import.css create mode 100644 packages/coding-agent/docs/session-import.md create mode 100644 packages/coding-agent/src/core/session-import/parsers.ts create mode 100644 packages/coding-agent/src/core/session-import/service.ts create mode 100644 packages/coding-agent/src/core/session-import/types.ts create mode 100644 packages/coding-agent/test/session-import.test.ts diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 3770e82d..9f211b8b 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -176,6 +176,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. - Anything a reloaded page must show again belongs in the session snapshot (e.g. `pendingApprovals`), not only in diff --git a/apps/web/client/src/features/conversation/ToolCall.tsx b/apps/web/client/src/features/conversation/ToolCall.tsx index 9fdcd514..ea77bda8 100644 --- a/apps/web/client/src/features/conversation/ToolCall.tsx +++ b/apps/web/client/src/features/conversation/ToolCall.tsx @@ -75,7 +75,7 @@ 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"; if (!readOnly && item.toolName === "subagent" && item.subagentTasks?.length && item.state !== "failed") { diff --git a/apps/web/client/src/features/conversation/tool-presentation.ts b/apps/web/client/src/features/conversation/tool-presentation.ts index df852f7d..20e95467 100644 --- a/apps/web/client/src/features/conversation/tool-presentation.ts +++ b/apps/web/client/src/features/conversation/tool-presentation.ts @@ -20,6 +20,7 @@ export function toolKind(item: Pick): ToolKind { /** State comes from the host, never from output text or the whole turn's activity. */ 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 ffa67a36..b7ea3bba 100644 --- a/apps/web/client/src/features/conversation/turn-artifacts.ts +++ b/apps/web/client/src/features/conversation/turn-artifacts.ts @@ -29,7 +29,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..9aac0279 --- /dev/null +++ b/apps/web/client/src/features/settings/SessionImportPage.tsx @@ -0,0 +1,406 @@ +import { useEffect, useRef, useState } from "react"; +import { useT } from "../../i18n"; +import { api, apiSend, errorMessage } from "../../lib/api"; +import { getDesktopBridge } from "../../platform/host"; +import { openSession, refreshSessions } from "../../state/session"; +import type { ImportCandidate, ImportJob, ImportPreview } from "../../state/types"; +import { Button, Icon, SearchInput } from "../../ui"; +import { closeSettings } from "./actions"; +import { PageHeader } from "./parts"; +import "./session-import.css"; + +const PATH = "/api/v1/session-imports"; + +export function SessionImportPage() { + const t = useT(); + const [codexRoot, setCodexRoot] = useState(""); + const [claudeRoot, setClaudeRoot] = useState(""); + const [includeArchived, setIncludeArchived] = useState(false); + 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 [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [scanErrors, setScanErrors] = useState([]); + const [focused, setFocused] = useState(); + const [job, setJob] = useState(); + const mounted = useRef(true); + + useEffect(() => { + mounted.current = true; + void api<{ jobs: ImportJob[] }>(PATH) + .then(({ jobs }) => { + if (mounted.current) setJob(jobs.sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0]); + }) + .catch((caught: unknown) => { + if (mounted.current) setError(errorMessage(caught)); + }); + 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); + if (next.status === "running") timer = setTimeout(() => void poll(), 500); + else await refreshSessions(); + } catch (caught) { + if (!stopped) { + setError(errorMessage(caught)); + 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 needle = query.trim().toLowerCase(); + const visible = candidates.filter((c) => `${c.title} ${c.cwd} ${c.source}`.toLowerCase().includes(needle)); + const groups = new Map(); + for (const candidate of visible) { + const group = groups.get(candidate.cwd) ?? []; + group.push(candidate); + groups.set(candidate.cwd, group); + } + const chosen = candidates.filter((c) => selected.has(c.id)); + const ready = chosen.length > 0 && chosen.every((c) => previews.has(c.id) && targets.get(c.id)?.trim()); + const focusedPreview = focused ? previews.get(focused) : undefined; + + async function scan() { + setBusy(true); + setError(""); + try { + const result = await apiSend<{ candidates: ImportCandidate[]; errors: { source: string; message: string }[] }>( + `${PATH}/scan`, + "POST", + { codexRoot, claudeRoot, includeArchived }, + ); + if (!mounted.current) return; + setCandidates(result.candidates); + setScanErrors(result.errors); + setSelected(new Set()); + setPreviews(new Map()); + setTargets(new Map(result.candidates.map((c) => [c.id, c.cwd]))); + setFocused(undefined); + setScanned(true); + } catch (caught) { + if (mounted.current) setError(errorMessage(caught)); + } finally { + if (mounted.current) setBusy(false); + } + } + + async function preview(ids: readonly string[]) { + setBusy(true); + setError(""); + setPreviews((current) => { + const next = new Map(current); + for (const id of ids) next.delete(id); + return next; + }); + try { + for (const id of ids) { + try { + const value = await apiSend(`${PATH}/preview`, "POST", { candidateId: id }); + if (!mounted.current) return; + setPreviews((current) => new Map(current).set(id, value)); + setFocused(id); + } catch (caught) { + if (mounted.current) setError((current) => `${current ? `${current}\n` : ""}${errorMessage(caught)}`); + } + } + } finally { + if (mounted.current) setBusy(false); + } + } + + async function pick(id: string) { + try { + const bridge = getDesktopBridge(); + const path = bridge + ? await bridge.pickDirectory({ title: t("importTarget") }) + : (await apiSend<{ path?: string }>(`/api/v1/workspaces/pick`, "POST", { title: t("importTarget") })).path; + if (path && mounted.current) setTargets((current) => new Map(current).set(id, path)); + } catch (caught) { + if (mounted.current) setError(errorMessage(caught)); + } + } + + async function start() { + if (!ready) return; + setBusy(true); + setError(""); + try { + const next = await apiSend(PATH, "POST", { + selections: chosen.map((c) => ({ + candidateId: c.id, + fingerprint: previews.get(c.id)?.fingerprint, + cwd: targets.get(c.id), + })), + }); + if (mounted.current) setJob(next); + } catch (caught) { + if (mounted.current) setError(errorMessage(caught)); + } finally { + if (mounted.current) setBusy(false); + } + } + + async function cancel() { + if (!job) return; + try { + await apiSend(`${PATH}/${job.id}`, "DELETE"); + } catch (caught) { + setError(errorMessage(caught)); + } + } + + return ( + <> + +
+ + +
+
+ + +
+ {error ? ( +

+ {error} +

+ ) : null} + {scanErrors.length ? ( +
+ {t("importScanErrors", { count: scanErrors.length })} + {scanErrors.map((item, index) => ( +

+ {item.source}: {item.message} +

+ ))} +
+ ) : null} + {scanned ? ( + <> +
+ setQuery(event.currentTarget.value)} + /> + +
+ {!visible.length ?

{t("importEmpty")}

: null} +
+ {[...groups].map(([cwd, rows]) => ( +
+

{cwd || t("importUnknownProject")}

+ {rows.map((candidate) => { + const p = previews.get(candidate.id); + return ( +
+ + + {selected.has(candidate.id) ? ( +
+ + +
+ ) : null} + {p ? ( +
+ + {t("importCounts", { messages: p.messageCount, tools: p.toolCount })} + + {p.warnings.map((warning) => ( + + {t(`importWarning.${warning.code}` as Parameters[0])} ( + {warning.count}) + + ))} +
+ ) : null} +
+ ); + })} +
+ ))} +
+
+ {t("importSelected", { count: chosen.length })} + + +
+ + ) : null} + {focusedPreview ? ( +
+

{focusedPreview.title}

+
{focusedPreview.excerpt}
+
+ ) : null} + {job ? ( +
+

+ {t(`importJob.${job.status}`)} · {job.results.length}/{job.total} +

+ {job.status === "running" ? ( + + ) : null} + {job.results.map((result, index) => ( +
+ + {result.title ? `${result.title} · ` : ""} + {t(`importResult.${result.status}`)} + {result.error ? `: ${result.error}` : ""} + {result.warning ? `: ${result.warning}` : ""} + + {result.sessionId ? ( + + ) : null} +
+ ))} +
+ ) : null} + + ); +} diff --git a/apps/web/client/src/features/settings/SettingsView.tsx b/apps/web/client/src/features/settings/SettingsView.tsx index ce0c06f8..998377ca 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.css b/apps/web/client/src/features/settings/session-import.css new file mode 100644 index 00000000..8e5f40ea --- /dev/null +++ b/apps/web/client/src/features/settings/session-import.css @@ -0,0 +1,155 @@ +.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; +} + +.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: minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + border-bottom: 1px solid var(--line); + padding-block: 12px; +} + +.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-report { + 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; +} + +.session-import-error, +.session-import-errors { + white-space: pre-wrap; + 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-sources { grid-template-columns: minmax(0, 1fr); } + .session-import-toolbar { gap: 8px; } +} diff --git a/apps/web/client/src/i18n/messages.ts b/apps/web/client/src/i18n/messages.ts index 9e6e9236..cb6872dd 100644 --- a/apps/web/client/src/i18n/messages.ts +++ b/apps/web/client/src/i18n/messages.ts @@ -387,6 +387,7 @@ const zhCN = { "toolAction.other.completed": "调用了 {name}", "toolAction.other.failed": "调用 {name} 失败", "toolStatus.pending": "等待结果", + "toolStatus.unknown": "历史状态未记录", "toolStatus.completed": "完成", "toolStatus.failed": "失败", toolMoreLines: "… 其余 {count} 行", @@ -563,6 +564,48 @@ const zhCN = { settingsMoreSoon: "快捷键、子智能体等更多设置项正在路上。", settingsGeneral: "常规", settingsGeneralDesc: "界面语言。", + importTitle: "会话迁移", + importSubtitle: "Codex / Claude Code · 本机会话", + 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: "账号", @@ -1148,6 +1191,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", @@ -1330,6 +1374,48 @@ const enUS: Record = { settingsMoreSoon: "Shortcuts, subagents and more settings are on the way.", settingsGeneral: "General", settingsGeneralDesc: "Interface language.", + importTitle: "Session Migration", + importSubtitle: "Codex / Claude Code · Local sessions", + 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": "Import failed", + "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/state/session.ts b/apps/web/client/src/state/session.ts index 1ffbb3dc..5a6f12da 100644 --- a/apps/web/client/src/state/session.ts +++ b/apps/web/client/src/state/session.ts @@ -282,12 +282,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)) : "", @@ -296,7 +305,7 @@ export function timelineFromEntries(entries: readonly SessionEntry[] = [], retry ...withArgs(block.arguments), ...(change ? { change } : {}), ...links, - ...(block.name === "subagent" + ...(!imported && block.name === "subagent" ? { subagentTasks: subagentTasks( block.arguments, diff --git a/apps/web/client/src/state/store.ts b/apps/web/client/src/state/store.ts index 95ebc5ea..ebfe0563 100644 --- a/apps/web/client/src/state/store.ts +++ b/apps/web/client/src/state/store.ts @@ -120,7 +120,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 628795af..0d9e80b6 100644 --- a/apps/web/client/src/state/types.ts +++ b/apps/web/client/src/state/types.ts @@ -135,6 +135,40 @@ export interface SessionEntry { readonly [key: string]: unknown; } +export interface ImportCandidate { + readonly id: string; + readonly source: "codex" | "claude-code"; + readonly sessionId: string; + readonly title: string; + readonly cwd: string; + readonly modified: string; + readonly archived: boolean; + readonly version: 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 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; @@ -319,7 +353,7 @@ export interface MessageTimelineItem { readonly error?: string; } -export type ToolState = "pending" | "completed" | "failed"; +export type ToolState = "pending" | "completed" | "failed" | "unknown"; export interface ThinkingTiming { readonly startedAt: number; @@ -339,6 +373,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 08bf177e..0f24bbd2 100644 --- a/apps/web/openapi.yaml +++ b/apps/web/openapi.yaml @@ -6,6 +6,117 @@ 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 + properties: + codexRoot: { type: string, description: Codex home or sessions directory } + claudeRoot: { type: string, description: Claude projects directory } + includeArchived: { type: boolean, default: false } + responses: + "200": + description: Candidates with opaque IDs and per-source scan errors + content: + application/json: + schema: + type: object + required: [candidates, errors] + properties: + 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. @@ -1586,6 +1697,57 @@ 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] } + sessionId: { type: string } + title: { type: string } + cwd: { type: string } + 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 } + title: { type: string } + 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 0a677737..a7211ce7 100644 --- a/apps/web/src/runtime.ts +++ b/apps/web/src/runtime.ts @@ -394,13 +394,17 @@ function modelsMatch(left: { provider: string; id: string }, right: { provider: 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; return { 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 4aa41f62..52621ea5 100644 --- a/apps/web/src/server.ts +++ b/apps/web/src/server.ts @@ -14,6 +14,9 @@ import { resolveStepAgentDir, resolveStepCodeVersion, resolveStepConfigRoot, + resolveStepSessionDir, + SessionImportError, + SessionImportService, SessionSearchRequestError, } from "@step-harness/coding-agent"; import { AccountProfileCache } from "./account.ts"; @@ -256,6 +259,11 @@ async function directoryList(inputPath: string): Promise { } 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]); @@ -636,6 +644,62 @@ const server = createServer(async (request, response) => { return error(response, 400, caught instanceof Error ? caught.message : String(caught)); } } + 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 ["codexRoot", "claudeRoot"]) + if (input[key] !== undefined && typeof input[key] !== "string") + return error(response, 400, `${key} must be a string`); + if (input.includeArchived !== undefined && typeof input.includeArchived !== "boolean") + return error(response, 400, "includeArchived must be a boolean"); + return json( + response, + 200, + await sessionImports.scan({ + codexRoot: typeof input.codexRoot === "string" ? input.codexRoot : undefined, + claudeRoot: typeof input.claudeRoot === "string" ? input.claudeRoot : undefined, + 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); } @@ -948,6 +1012,7 @@ const shutdown = async (): Promise => { terminals.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 dea88804..bbb84f60 100644 --- a/apps/web/src/turn-changes.ts +++ b/apps/web/src/turn-changes.ts @@ -246,7 +246,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; diff --git a/apps/web/test/e2e/web.e2e.mjs b/apps/web/test/e2e/web.e2e.mjs index 582949c5..d4d5481b 100644 --- a/apps/web/test/e2e/web.e2e.mjs +++ b/apps/web/test/e2e/web.e2e.mjs @@ -250,6 +250,111 @@ 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") + 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"); + $("#importCodexRoot").value = ${JSON.stringify(codexRoot)}; + $("#importCodexRoot").dispatchEvent(new Event("input")); + $("#importClaudeRoot").value = ${JSON.stringify(claudeRoot)}; + $("#importClaudeRoot").dispatchEvent(new Event("input")); + await new Promise(resolve => setTimeout(resolve, 50)); + const archived = $("#importIncludeArchived").checked; + $("#importScan").click(); + await until(() => $$(".session-import-row").length === 2, "two active source sessions"); + 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"] }) + await browser.run(` + $("#importSelectAll").click(); + await until(() => !$("#importReviewSelected").disabled, "review enabled"); + $("#importReviewSelected").click(); + await until(() => !$("#importConfirm").disabled, "preview completion"); + 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 = [$("#importReviewSelected"), $("#importConfirm")].map(node => node.getBoundingClientRect()); + return { + contentWidth: content.getBoundingClientRect().width, + overflowing: content.scrollWidth > content.clientWidth, + buttonsFit: buttons.every(rect => rect.left >= 0 && rect.right <= innerWidth), + }; + `) + assert.ok(layout.contentWidth >= 280, JSON.stringify(layout)) + assert.equal(layout.overflowing, false) + 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(` + $("#importConfirm").click(); + await until(() => $$(".session-import-result").length === 2, "completed imports"); + const jobs = await (await fetch("/api/v1/session-imports")).json(); + const job = jobs.jobs.find(job => job.results.length === 2); + if (!job || job.results.some(result => result.status !== "imported")) throw new Error(JSON.stringify(jobs)); + window.migrationJob = job; + 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); + const buttons = $$(".session-import-result button"); + buttons[job.results.indexOf(result)].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) +}) + let base let workspace let extra diff --git a/packages/coding-agent/AGENTS.md b/packages/coding-agent/AGENTS.md index acd48a57..a15913e4 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..c1ec3354 --- /dev/null +++ b/packages/coding-agent/docs/session-import.md @@ -0,0 +1,96 @@ +# Offline Session Import + +`SessionImportService` discovers and imports local Codex and Claude Code 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({ codexRoot?, claudeRoot?, 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. + +A scan replaces prior preview capabilities; 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 survive host restart. 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. + +## 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. + +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 reads complete JSONL records in bounded chunks until +metadata is found, within the source size limit. Claude metadata may share a +record larger than 64 KiB with message content; a final newline is not required. +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/parsers.ts b/packages/coding-agent/src/core/session-import/parsers.ts new file mode 100644 index 00000000..806835c2 --- /dev/null +++ b/packages/coding-agent/src/core/session-import/parsers.ts @@ -0,0 +1,408 @@ +import { randomBytes } from "node:crypto"; +import type { AssistantMessage, ImageContent, TextContent, ToolCall } from "@step-harness/providers"; +import type { SessionEntry } from "../session-manager.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 === "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") ?? {}); + 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") { + 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(); + let current = messages.at(-1); + 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 | ToolCall)[], + timestamp: unknown, + itemId: string, + usage?: unknown, + ) => { + const supported = blocks.filter((b): b is TextContent | 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) => { + if (!blocks.length) return; + if (!result.title) + result.title = blocks + .filter((b) => b.type === "text") + .map((b) => b.text) + .join(" ") + .slice(0, 120); + append( + { + ...base(timestamp), + type: "message", + message: { role: "user", content: blocks, 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 : 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") { + const blocks = content(p.content); + // Codex may put host control envelopes in user-shaped records. + const safe = blocks.filter( + (b) => + b.type !== "text" || + !/^<(?:environment_context|permissions instructions|turn_aborted|request_id|turn_id|model)>/u.test( + b.text.trim(), + ), + ); + if (safe.length !== blocks.length) warn("source_instructions_excluded"); + user(safe, 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: "text", text: 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 (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 | 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: "text", text: 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..9ce143eb --- /dev/null +++ b/packages/coding-agent/src/core/session-import/service.ts @@ -0,0 +1,676 @@ +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 { parseForeignSession, record } from "./parsers.ts"; +import type { + ImportCandidate, + ImportDocument, + ImportJob, + ImportPreview, + ImportResult, + ImportSelection, + ImportSource, + ParsedImport, +} from "./types.ts"; + +const MAX_SOURCE_BYTES = 128 * 1024 * 1024; +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 { + codexRoot?: string; + claudeRoot?: string; + includeArchived?: boolean; +} + +export interface ImportScanResult { + 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 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 = { candidates: [], errors: [] }; + const roots: [ImportSource, string][] = [ + ["codex", options.codexRoot?.trim() || process.env.CODEX_HOME?.trim() || join(homedir(), ".codex")], + [ + "claude-code", + options.claudeRoot?.trim() || + join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude"), "projects"), + ], + ]; + // Replace capabilities only after the scan finishes. In-flight previews keep their own file reference. + const candidates = new Map(); + for (const [source, input] of roots) { + try { + const root = await realpath(input); + 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") && + (source !== "codex" || file.name.startsWith("rollout-")) + ) { + 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); + if (!candidate || (candidate.archived && !options.includeArchived)) continue; + candidate.title = titles.get(candidate.sessionId) || candidate.title; + candidates.set(candidate.id, { candidate, path, root }); + result.candidates.push(candidate); + } 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 (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): 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 pending: Buffer[] = []; + const inspect = (bytes: Buffer): void => { + const line = bytes.toString("utf8"); + if (!line.trim()) return; + const r = record(JSON.parse(line)); + if (source === "codex" && r.type === "session_meta") { + metadata = record(r.payload); + } else if (source === "claude-code" && typeof r.sessionId === "string") { + metadata = r; + } + }; + // Metadata can share a large record with message/image content. Decode only complete records. + let position = 0; + while (!metadata && position < info.size) { + const buffer = Buffer.alloc(Math.min(info.size - 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) break; + } + if (!metadata && start < chunk.length) pending.push(chunk.subarray(start)); + } + if (!metadata && pending.length) inspect(Buffer.concat(pending)); + if (!metadata) 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 : ""; + const archived = + relative(root, path).split(sep).includes("archived_sessions") || + root.split(sep).includes("archived_sessions") || + metadata.archived === true; + return { + id: randomUUID(), + source, + sessionId, + title: + typeof metadata.customTitle === "string" + ? metadata.customTitle.slice(0, 120) + : `${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", + }; + } 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: file.candidate.title.includes(` · ${file.candidate.sessionId.slice(0, 8)}`) + ? parsed.title || file.candidate.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.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.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; + 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, + title: file.candidate.title, + 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(`${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: { + 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, title: preview.title, 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..7b8c7f68 --- /dev/null +++ b/packages/coding-agent/src/core/session-import/types.ts @@ -0,0 +1,84 @@ +import type { FileEntry, SessionEntry } from "../session-manager.ts"; + +export type ImportSource = "codex" | "claude-code"; + +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; +} + +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; + title?: 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 isImportProvenance(value: unknown): value is ImportProvenance { + if (!value || typeof value !== "object") return false; + const record = value as Record; + return ( + (record.source === "codex" || record.source === "claude-code") && + 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..8f583269 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -27,6 +27,7 @@ import { createCompactionSummaryMessage, createCustomMessage, } from "./messages.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 +59,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 +391,11 @@ function getSessionContextSettings(path: SessionEntry[]): Pick { + 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 20a7bb12..ef333e63 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -343,6 +343,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.test.ts b/packages/coding-agent/test/session-import.test.ts new file mode 100644 index 00000000..4ab261b2 --- /dev/null +++ b/packages/coding-agent/test/session-import.test.ts @@ -0,0 +1,789 @@ +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readdir, readFile, rm, 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 agentDir = join(root, "target", "agent"); + await mkdir(cwd, { recursive: true }); + await mkdir(join(codexRoot, "sessions"), { recursive: true }); + await mkdir(claudeRoot); + 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, 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("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 and imports Claude metadata in a first record larger than 64 KiB", async () => { + 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(5000)}`; + 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); + expect(await f.service.scan({ ...f, codexRoot: archivedRoot })).toEqual({ 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(); + } + }); +});