From ec653e823cfb09ea3454381898b9b29e8d0eed2b Mon Sep 17 00:00:00 2001 From: luoxiaoyang-ccc Date: Thu, 22 Oct 2026 22:19:33 +0800 Subject: [PATCH 1/2] feat(desktop): add grouped session migration and custom path scanning --- apps/web/AGENTS.md | 5 + .../conversation/ConversationStream.tsx | 17 + .../src/features/conversation/ToolCall.tsx | 2 +- .../src/features/conversation/stream.css | 22 + .../conversation/tool-presentation.ts | 1 + .../features/conversation/turn-artifacts.ts | 2 +- .../features/settings/SessionImportPage.tsx | 542 ++++++++++ .../src/features/settings/SettingsView.tsx | 4 + .../src/features/settings/session-import.css | 276 +++++ apps/web/client/src/i18n/messages.ts | 118 +++ apps/web/client/src/state/session.ts | 17 +- apps/web/client/src/state/store.ts | 2 +- apps/web/client/src/state/types.ts | 41 +- apps/web/openapi.yaml | 174 ++++ apps/web/src/runtime.ts | 6 +- apps/web/src/server.ts | 66 ++ apps/web/src/turn-changes.ts | 2 +- apps/web/test/e2e/web.e2e.mjs | 193 ++++ packages/coding-agent/AGENTS.md | 7 + packages/coding-agent/docs/session-format.md | 8 + packages/coding-agent/docs/session-import.md | 116 +++ .../src/core/session-import/parsers.ts | 408 ++++++++ .../src/core/session-import/service.ts | 818 +++++++++++++++ .../src/core/session-import/types.ts | 90 ++ .../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 | 967 ++++++++++++++++++ 28 files changed, 3965 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 d272c6f0..5f594875 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -178,6 +178,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/ConversationStream.tsx b/apps/web/client/src/features/conversation/ConversationStream.tsx index 1256ef7f..647af526 100644 --- a/apps/web/client/src/features/conversation/ConversationStream.tsx +++ b/apps/web/client/src/features/conversation/ConversationStream.tsx @@ -3,6 +3,8 @@ import { useLocale, useT } from "../../i18n"; import { collectTurnChanges } from "../../lib/turn-changes"; import { setUi, useAppStore } from "../../state/store"; import type { SteeringMessage, TimelineItem } from "../../state/types"; +import { Icon } from "../../ui"; +import { openSettings } from "../settings/actions"; import { MessageActions, MessageItem } from "./MessageItem"; import { ProcessGroup } from "./ProcessGroup"; import { @@ -23,6 +25,7 @@ import { collectTurnFooters } from "./turn-footers"; /** Shown while the timeline is empty (legacy index.html 835-842, `renderTimeline`). */ function EmptyConversation() { const t = useT(); + const draft = useAppStore((state) => state.current === null); return (
@@ -30,6 +33,20 @@ function EmptyConversation() { CODE

{t("emptyConversation")}

+ {draft ? ( + + ) : null}
); } 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/stream.css b/apps/web/client/src/features/conversation/stream.css index 8a1eef39..9de28432 100644 --- a/apps/web/client/src/features/conversation/stream.css +++ b/apps/web/client/src/features/conversation/stream.css @@ -44,6 +44,28 @@ letter-spacing: 0.01em; } +.empty-session-import { + display: flex; + align-items: flex-start; + gap: 8px; + margin-top: 20px; + padding: 8px 12px; + border-radius: var(--radius-control); + color: var(--ink-2); + font-size: 13px; + text-align: left; + transition: background-color var(--fast), color var(--fast); +} +.empty-session-import .icon { margin-top: 2px; } +.empty-session-import small { + display: block; + margin-top: 4px; + color: var(--ink-3); + font-size: 12px; +} +.empty-session-import:hover { background: var(--hover); color: var(--ink); } +.empty-session-import:focus-visible { outline: 2px solid var(--focus-border); outline-offset: 2px; } + .message { display: flex; flex-direction: column; diff --git a/apps/web/client/src/features/conversation/tool-presentation.ts b/apps/web/client/src/features/conversation/tool-presentation.ts index df852f7d..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..4206dfe5 --- /dev/null +++ b/apps/web/client/src/features/settings/SessionImportPage.tsx @@ -0,0 +1,542 @@ +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 { useAppStore } from "../../state/store"; +import type { ImportCandidate, ImportJob, ImportPreview } from "../../state/types"; +import { Button, Icon, Popover, SearchInput } from "../../ui"; +import { closeSettings } from "./actions"; +import "./session-import.css"; + +const PATH = "/api/v1/session-imports"; +const SOURCES = [ + { id: "codex", name: "Codex" }, + { id: "claude-code", name: "Claude Code" }, +] as const; + +function groupProjects(rows: readonly T[], cwd: (row: T) => string) { + const groups = new Map(); + for (const row of rows) { + const path = cwd(row); + const items = groups.get(path) ?? []; + items.push(row); + groups.set(path, items); + } + return [...groups]; +} + +function projectName(path: string, fallback: string) { + return path.split(/[\\/]/u).filter(Boolean).at(-1) || fallback; +} + +export function SessionImportPage() { + const t = useT(); + const sessions = useAppStore((state) => state.sessions); + const [customPath, setCustomPath] = useState(""); + const [roots, setRoots] = useState>>({}); + const [customOpen, setCustomOpen] = useState(false); + 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 [jobs, setJobs] = useState([]); + const mounted = useRef(true); + + useEffect(() => { + mounted.current = true; + void api<{ jobs: ImportJob[] }>(PATH) + .then(({ jobs }) => { + if (mounted.current) { + setJobs(jobs); + setJob(jobs.find((item) => item.status === "running")); + } + }) + .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); + setJobs((current) => [next, ...current.filter((item) => item.id !== next.id)]); + 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) => + `${previews.get(c.id)?.title || c.title} ${c.cwd} ${c.source} ${c.sourcePath ?? ""}` + .toLowerCase() + .includes(needle), + ); + 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()); + + function selectionButton(rows: readonly ImportCandidate[], scope: string) { + const all = rows.length > 0 && rows.every((row) => selected.has(row.id)); + return ( + + ); + } + + async function scan(custom = false) { + setBusy(true); + setError(""); + try { + const result = await apiSend<{ + roots?: Record; + candidates: ImportCandidate[]; + errors: { source: string; message: string }[]; + }>(`${PATH}/scan`, "POST", { ...(custom ? { customRoot: customPath.trim() } : {}), includeArchived }); + if (!mounted.current) return; + setCandidates(result.candidates); + setRoots(result.roots ?? {}); + setScanErrors(result.errors); + setSelected(new Set()); + setPreviews(new Map()); + setTargets(new Map(result.candidates.map((c) => [c.id, c.cwd]))); + setFocused(undefined); + setScanned(true); + await preview( + result.candidates.map((candidate) => candidate.id), + false, + ); + } catch (caught) { + if (mounted.current) setError(errorMessage(caught)); + } finally { + if (mounted.current) setBusy(false); + } + } + + async function preview(ids: readonly string[], show = true) { + setBusy(true); + setError(""); + if (show && ids[0]) setFocused(ids[0]); + 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)); + } catch (caught) { + if (mounted.current) setError((current) => `${current ? `${current}\n` : ""}${errorMessage(caught)}`); + } + } + } finally { + if (mounted.current) setBusy(false); + } + } + + async function pickSource() { + try { + const bridge = getDesktopBridge(); + const path = bridge + ? await bridge.pickDirectory({ title: t("importChooseSource") }) + : (await apiSend<{ path?: string }>("/api/v1/workspaces/pick", "POST", { title: t("importChooseSource") })) + .path; + if (!mounted.current || !path) return; + setCustomPath(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); + setJobs((current) => [next, ...current.filter((item) => item.id !== next.id)]); + } + } 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 ( +
+
+
+

{t("importSourcesTitle")}

+
+ {chosen.length ? ( + {t("importSelected", { count: chosen.length })} + ) : null} + + +
+
+ {error ? ( +

+ {error} +

+ ) : null} +
+ setQuery(event.currentTarget.value)} + /> + +
+
+ {SOURCES.map((source) => { + const rows = visible.filter((candidate) => candidate.source === source.id); + const count = candidates.filter((candidate) => candidate.source === source.id).length; + const root = roots[source.id]; + if (!scanned || (!root && !count && !scanErrors.some((item) => item.source === source.id))) + return null; + return ( +
+ + + {source.name} + {root ? {root} : null} + + {t("importSelected", { + count: candidates.filter( + (candidate) => candidate.source === source.id && selected.has(candidate.id), + ).length, + })} + + {selectionButton(rows, source.id)} + +
+ {!rows.length ? ( +

+ {t( + !scanned ? "importBeforeScan" : count && needle ? "importNoMatches" : "importEmpty", + )} +

+ ) : null} + {groupProjects(rows, (candidate) => candidate.cwd).map(([cwd, projectRows]) => ( +
+ + + + {projectName(cwd, t("importUnknownProject"))} + + + {t("importSelected", { + count: candidates.filter( + (candidate) => + candidate.source === source.id && + candidate.cwd === cwd && + selected.has(candidate.id), + ).length, + })} + + {selectionButton(projectRows, `${source.id}:${cwd}`)} + +
+ {projectRows.map((candidate) => { + const p = previews.get(candidate.id); + return ( +
+ + setFocused(undefined)} + content={ + p ? ( + <> + {p.title} +
{p.excerpt}
+ + ) : null + } + > + +
+ + {p ? ( +
+ + {t("importCounts", { + messages: p.messageCount, + tools: p.toolCount, + })} + + {p.warnings.map((warning) => ( + + {t(`importWarning.${warning.code}` as Parameters[0])}{" "} + ({warning.count}) + + ))} +
+ ) : null} +
+ ); + })} +
+
+ ))} + {scanErrors.filter((item) => item.source === source.id).length ? ( +
+ + {t("importScanErrors", { + count: scanErrors.filter((item) => item.source === source.id).length, + })} + + {scanErrors + .filter((item) => item.source === source.id) + .map((item) => ( +

{item.message}

+ ))} +
+ ) : null} +
+
+ ); + })} +
+
+ + +
+
+
+
+

{t("importHistoryTitle")}

+ {job?.status === "running" ? ( + + ) : null} +
+ {job ? ( +

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

+ ) : null} + {[...SOURCES, { id: "unknown", name: t("importUnknownSource") }].map((source) => { + const rows = [...jobs] + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .flatMap((item) => item.results.map((result, index) => ({ result, index, job: item }))) + .filter(({ result }) => (result.source ?? "unknown") === source.id); + if (source.id === "unknown" && !rows.length) return null; + return ( +
+ + + {source.name} + +
+ {!rows.length ?

{t("importHistoryEmpty")}

: null} + {groupProjects( + rows, + ({ result }) => + sessions.find((session) => session.id === result.sessionId)?.cwd || + candidates.find((candidate) => candidate.id === result.candidateId)?.cwd || + "", + ).map(([cwd, projectRows]) => ( +
+ + + + {projectName(cwd, t("importUnknownProject"))} + +
+ {projectRows.map(({ result, index, job: item }) => ( +
+
+ {result.title ?? t("importUnknownProject")} +

+ {t(`importResult.${result.status}`)} ·{" "} + {new Date(item.createdAt).toLocaleString()} +

+ {result.error ?

{result.error}

: null} + {result.warning ?

{result.warning}

: null} +
+ {result.sessionId ? ( + + ) : 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..2ff41dd2 --- /dev/null +++ b/apps/web/client/src/features/settings/session-import.css @@ -0,0 +1,276 @@ +.session-import-page { display: grid; grid-template-columns: minmax(0, 1fr); gap: 24px; } +.session-import-root { min-width: 0; flex: 1; overflow-wrap: anywhere; color: var(--ink-3); font-size: 11px; font-weight: 400; } +.session-import-custom { margin-top: 14px; } +.session-import-custom-toggle { width: 100%; display: flex; justify-content: center; align-items: center; gap: 8px; padding: 16px; border: 1px dashed var(--line-strong); border-radius: var(--radius-control); color: var(--ink-2); font-size: 13px; } +.session-import-custom-toggle:hover { background: var(--hover); color: var(--ink); } +.session-import-custom-toggle:focus-visible { outline: 2px solid var(--focus-border); outline-offset: 2px; } +.session-import-custom-form { padding: 16px; border: 1px solid var(--line); border-radius: var(--radius-control); margin-top: 8px; } +.session-import-custom-form .settings-field { margin-block: 14px; } +.session-import-toolbar > .session-import-check { font-size: 12px; color: var(--ink-2); } +.session-import-page > section { min-width: 0; } +.session-import-project-summary { min-width: 0; } +.session-import-section-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 8px; } +.session-import-section-head h1, .session-import-section-head h2 { font-size: 18px; font-weight: 600; } +.session-import-group { border: 1px solid var(--line); border-radius: var(--radius-control); margin-block: 10px; background: var(--canvas); } +.session-import-app-summary { display: flex; align-items: center; gap: 10px; padding: 12px 14px; cursor: pointer; list-style: none; font-size: 14px; } +.session-import-app-summary::-webkit-details-marker { display: none; } +.session-import-app-summary > span { margin-left: auto; color: var(--ink-2); font-size: 12px; } +.session-import-group[open] > .session-import-app-summary .icon { transform: rotate(90deg); } +.session-import-app-body { padding: 0 14px 12px; } +.session-import-project { border-top: 1px solid var(--line); margin-top: 10px; } +.session-import-project-summary { display: flex; align-items: center; gap: 8px; padding: 12px 0 6px; cursor: pointer; list-style: none; font-size: 13px; } +.session-import-project-summary::-webkit-details-marker { display: none; } +.session-import-project-summary strong { font-weight: 550; overflow-wrap: anywhere; min-width: 0; } +.session-import-project-summary > span { margin-left: auto; flex: none; font-size: 11px; color: var(--ink-3); } +.session-import-project[open] > summary > .icon:first-child { transform: rotate(90deg); } +.session-import-project-summary:focus-visible { outline: 2px solid var(--focus-border); outline-offset: 2px; } +.session-import-project-path { color: var(--ink-3); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; flex: 1; font-weight: 400; } +.session-import-header-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; } +.session-import-selected { font-size: 12px; color: var(--ink-2); } +.session-import-select-group { flex: none; } +.session-import-project-summary > span { flex: none; } +.session-import-project-summary strong { flex: none; max-width: 40%; } +.session-import-project-body { padding-left: 20px; } +.session-import-page .empty-list { font-size: 12px; color: var(--ink-3); padding-block: 12px; } +.session-import-page .settings-page-desc { font-size: 12px; line-height: 1.6; } +.session-import-page .settings-btn { font-size: 12px; } +.session-import-app-body > .settings-field { padding: 10px; background: var(--surface-soft); border-radius: var(--radius-control); } +.session-import-app-body > .session-import-check { font-size: 12px; color: var(--ink-2); } +.session-import-history { border-top: 1px solid var(--line); padding-top: 20px; } +.session-import-source-picker { display: flex; gap: 8px; align-items: center; } +.session-import-source-picker input { flex: 1; min-width: 0; } +.session-import-source-picker button { flex: none; } +.session-import-path { font-size: 12px; color: var(--ink-2); overflow-wrap: anywhere; word-break: break-word; } +.session-import-group .settings-field { margin-bottom: 12px; } +.session-import-result > div { min-width: 0; } +.session-import-result p { font-size: 12px; color: var(--ink-2); margin-top: 4px; } +.session-import-app-summary:focus-visible { outline: 2px solid var(--focus-border); outline-offset: 2px; border-radius: var(--radius-control); } + +.session-import-sources { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.session-import-toolbar { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-block: 16px; +} + +.session-import-toolbar .search-input { + flex: 1; + min-width: 160px; + min-height: 40px; + padding: 9px 12px; + border: 1px solid var(--line); + border-radius: var(--radius-control); + background: var(--surface-soft); + transition: border-color var(--fast), box-shadow var(--fast), background-color var(--fast); +} +.session-import-toolbar .search-input:focus-within { + border-color: var(--focus-border); + background: var(--canvas); + box-shadow: 0 0 0 3px var(--accent-ring); +} +.session-import-toolbar .search-input input { font-size: 13px; color: var(--ink); } +.session-import-toolbar .search-input input:focus-visible { outline: none; } +.session-import-toolbar .search-input input::placeholder { color: var(--ink-3); } + +.session-import-check { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + cursor: pointer; +} + +.session-import-check input { + flex: 0 0 auto; + accent-color: var(--ink); +} + +.session-import-check span { + min-width: 0; + overflow-wrap: anywhere; +} + +.session-import-check strong { + font-size: 13px; + font-weight: 500; +} + +.session-import-check small { + display: block; + font-size: 12px; + color: var(--ink-2); + margin-top: 4px; +} + +.session-import-group h2, +.session-import-preview h2, +.session-import-job h2 { + font-size: 13px; + font-weight: 600; + overflow-wrap: anywhere; + margin-block: 16px 8px; +} + +.session-import-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + margin-block: 8px; + padding: 12px 10px; + border-radius: var(--radius-control); + transition: background-color var(--fast); +} +.session-import-row > .session-import-check { display: contents; } +.session-import-row > .session-import-check > input { grid-column: 1; grid-row: 1 / 3; align-self: center; margin: 0; } +.session-import-row > .session-import-check > span { grid-column: 2; grid-row: 1; } +.session-import-row > .session-import-check strong { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.session-import-row > button { grid-column: 3; grid-row: 1 / 3; align-self: center; } +.session-import-row > .session-import-report { grid-column: 2; grid-row: 2; padding-left: 0; } +.session-import-row:has(> .session-import-check input:checked) { + background: var(--surface-soft); +} + +.session-import-target, +.session-import-report { + grid-column: 1 / -1; +} + +.session-import-target { + display: flex; + align-items: end; + gap: 8px; +} + +.session-import-target label { + flex: 1; + min-width: 0; +} + +.session-import-excerpt { + grid-column: 1 / -1; + font-size: 12px; + line-height: 1.6; + color: var(--ink-2); + white-space: pre-wrap; + overflow-wrap: anywhere; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} +.session-import-preview-popover.session-import-preview-popover { + z-index: 100; + position-area: left center; + position-try-fallbacks: flip-inline, flip-block; + margin: 8px; + width: min(380px, calc(100vw - 32px)); + padding: 16px; +} +.session-import-preview-popover strong { font-size: 13px; overflow-wrap: anywhere; } +.session-import-preview-popover pre { + max-height: min(360px, 65vh); + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: 12px / 1.6 var(--mono); + margin-top: 10px; + color: var(--ink-2); +} + +.session-import-report { + padding-left: 24px; + display: flex; + flex-wrap: wrap; + gap: 6px 16px; + font-size: 12px; + color: var(--ink-2); +} + +.session-import-preview pre { + max-height: 320px; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + font-size: 12px; + padding: 12px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 6px; +} + +.session-import-result { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding-block: 8px; + overflow-wrap: anywhere; +} + +.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-project-summary { flex-wrap: wrap; } + .session-import-project-summary .session-import-project-path { flex-basis: calc(100% - 40px); order: 5; } + .session-import-project-summary strong { flex: 1; max-width: none; } + .session-import-project-summary > span { margin-left: 0; } + .session-import-header-actions { justify-content: flex-start; width: 100%; min-width: 0; } + .session-import-section-head > h1 { width: 100%; } + .session-import-header-actions .session-import-selected { width: 100%; } + .session-import-app-summary { gap: 6px; } + .session-import-source-picker { flex-wrap: wrap; } + .session-import-source-picker input { flex-basis: 100%; } + .session-import-section-head { flex-wrap: wrap; } + .session-import-sources { grid-template-columns: minmax(0, 1fr); } + .session-import-toolbar { gap: 8px; } +} diff --git a/apps/web/client/src/i18n/messages.ts b/apps/web/client/src/i18n/messages.ts index c0b02b4e..13895054 100644 --- a/apps/web/client/src/i18n/messages.ts +++ b/apps/web/client/src/i18n/messages.ts @@ -154,6 +154,8 @@ const zhCN = { themeLight: "浅色", themeDark: "深色", emptyConversation: "还没有消息 — 在下方输入开始对话", + emptySessionImport: "从其他 AI 应用导入已有会话", + emptySessionImportSources: "Claude Code / Codex", messagePlaceholder: "输入消息…", fileUploadFailed: "无法添加 {name}:{reason}", idle: "空闲", @@ -391,6 +393,7 @@ const zhCN = { "toolAction.other.completed": "调用了 {name}", "toolAction.other.failed": "调用 {name} 失败", "toolStatus.pending": "等待结果", + "toolStatus.unknown": "历史状态未记录", "toolStatus.completed": "完成", "toolStatus.failed": "失败", toolMoreLines: "… 其余 {count} 行", @@ -577,6 +580,61 @@ const zhCN = { settingsMoreSoon: "快捷键、子智能体等更多设置项正在路上。", settingsGeneral: "常规", settingsGeneralDesc: "界面语言。", + importTitle: "会话迁移", + importCustomScan: "自定义扫描路径", + importCustomScanHint: "选择或填写一个本机目录,扫描并识别其中支持的会话格式。扫描结果将替换当前列表。", + importSelectGroup: "全选", + importDeselectGroup: "取消全选", + importSourcesTitle: "从其他 AI 应用导入", + importHistoryTitle: "导入历史", + importAppCount: "{count} 个会话", + importChooseSource: "选择目录", + importCustomSourceHint: "可选择本机目录或输入自定义路径,留空使用默认位置。修改后点击扫描会话。", + importBeforeScan: "扫描后将在这里显示此应用的会话", + importNoMatches: "没有匹配的会话,试试其他关键词或清空搜索", + importHistoryEmpty: "暂无导入记录", + importUnknownSource: "来源未记录", + importSubtitle: "从本机 Claude Code / Codex 导入已有会话。源文件只读,不会修改原应用的会话。", + importCodexRoot: "Codex 数据目录", + importClaudeRoot: "Claude Code projects 目录", + importIncludeArchived: "包含已归档会话", + importArchived: "已归档", + importScan: "扫描会话", + importWorking: "处理中", + importSearch: "搜索项目、来源或会话", + importSelectAll: "全选当前列表", + importEmpty: "没有可导入的会话", + importUnknownProject: "项目未记录", + importPreview: "预览", + importTarget: "目标项目目录", + importChooseTarget: "选择目标项目目录", + importCounts: "{messages} 条消息 · {tools} 次工具调用", + importSelected: "已选 {count} 项", + importScanErrors: "{count} 项扫描问题", + importReviewSelected: "检查所选会话", + importConfirm: "导入所选会话", + importCancel: "停止后续导入", + importOpen: "打开会话", + "importJob.running": "正在导入", + "importJob.completed": "导入结束", + "importJob.cancelled": "已停止", + "importJob.interrupted": "导入中断", + "importResult.imported": "已导入", + "importResult.skipped": "相同快照已存在", + "importResult.failed": "导入失败", + "importWarning.attachment_missing": "附件缺失或不支持", + "importWarning.unsupported_content": "不支持的内容块", + "importWarning.tool_arguments_as_text": "工具参数按原文保留", + "importWarning.source_instructions_excluded": "来源控制指令未激活", + "importWarning.rolled_back_history": "已排除回滚历史", + "importWarning.encrypted_reasoning_excluded": "加密推理不可迁移", + "importWarning.unsupported_item": "特殊记录按历史原文保留", + "importWarning.source_compaction": "已保留来源压缩摘要", + "importWarning.claude_compaction": "仅迁移当前消息链,压缩前历史可能缺失", + "importWarning.child_history_excluded": "未合并子会话", + "importWarning.branch_parent_missing": "分支祖先记录缺失", + "importWarning.tool_result_missing": "工具结果未记录", + "importWarning.tool_call_missing": "工具调用未记录", settingsLanguage: "界面语言", settingsLanguageDesc: "切换后立即生效,并在下次打开时保持。", settingsAccount: "账号", @@ -932,6 +990,8 @@ const enUS: Record = { themeLight: "Light", themeDark: "Dark", emptyConversation: "No messages yet — start a conversation below", + emptySessionImport: "Import existing sessions from other AI apps", + emptySessionImportSources: "Claude Code / Codex", messagePlaceholder: "Type a message…", fileUploadFailed: "Could not add {name}: {reason}", idle: "Idle", @@ -1176,6 +1236,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", @@ -1368,6 +1429,63 @@ const enUS: Record = { settingsMoreSoon: "Shortcuts, subagents and more settings are on the way.", settingsGeneral: "General", settingsGeneralDesc: "Interface language.", + importTitle: "Session Migration", + importCustomScan: "Custom scan paths", + importCustomScanHint: + "Choose or enter a local directory. Supported session formats are detected automatically, replacing the current results.", + importSelectGroup: "Select all", + importDeselectGroup: "Deselect all", + importSourcesTitle: "Import from other AI apps", + importHistoryTitle: "Import history", + importAppCount: "{count} sessions", + importChooseSource: "Choose directory", + importCustomSourceHint: + "Choose a local directory or enter a custom path. Leave blank for defaults, then scan sessions.", + importBeforeScan: "Scan to list this app's sessions", + importNoMatches: "No matching sessions. Try another keyword or clear the search.", + importHistoryEmpty: "No import records yet", + importUnknownSource: "Source not recorded", + importSubtitle: "Import local Claude Code / Codex sessions. Source files are read-only and remain unchanged.", + importCodexRoot: "Codex data directory", + importClaudeRoot: "Claude Code projects directory", + importIncludeArchived: "Include archived sessions", + importArchived: "Archived", + importScan: "Scan sessions", + importWorking: "Working", + importSearch: "Search projects, sources or sessions", + importSelectAll: "Select visible sessions", + importEmpty: "No importable sessions", + importUnknownProject: "Project not recorded", + importPreview: "Preview", + importTarget: "Destination project directory", + importChooseTarget: "Choose destination project directory", + importCounts: "{messages} messages · {tools} tool calls", + importSelected: "{count} selected", + importScanErrors: "{count} scan issues", + importReviewSelected: "Review selected sessions", + importConfirm: "Import selected sessions", + importCancel: "Stop remaining imports", + importOpen: "Open session", + "importJob.running": "Importing", + "importJob.completed": "Import finished", + "importJob.cancelled": "Stopped", + "importJob.interrupted": "Import interrupted", + "importResult.imported": "Imported", + "importResult.skipped": "Identical snapshot already exists", + "importResult.failed": "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 2174d343..6c7105eb 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 badd6782..0087d088 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 55098be9..9ce82338 100644 --- a/apps/web/client/src/state/types.ts +++ b/apps/web/client/src/state/types.ts @@ -135,6 +135,44 @@ 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; + readonly sourcePath?: string; +} + +export interface ImportPreview extends ImportCandidate { + readonly fingerprint: string; + readonly messageCount: number; + readonly toolCount: number; + readonly warnings: readonly { readonly code: string; readonly count: number }[]; + readonly excerpt: string; +} + +export interface ImportJob { + readonly id: string; + readonly createdAt: string; + readonly status: "running" | "completed" | "cancelled" | "interrupted"; + readonly total: number; + readonly results: readonly { + readonly candidateId: string; + readonly source?: "codex" | "claude-code"; + readonly sourcePath?: string; + readonly path?: string; + readonly title?: string; + readonly status: "imported" | "skipped" | "failed"; + readonly sessionId?: string; + readonly error?: string; + readonly warning?: string; + }[]; +} + /** A row of GET /api/v1/sessions. Snapshots are merged into these rows too. */ export interface SessionSummary { readonly id: string; @@ -319,7 +357,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 +377,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 29f2c389..3af5fb4d 100644 --- a/apps/web/openapi.yaml +++ b/apps/web/openapi.yaml @@ -6,6 +6,121 @@ 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: [roots, candidates, errors] + properties: + roots: + type: object + description: Actual resolved source directories, including empty scanned directories. + additionalProperties: { type: string } + candidates: { type: array, items: { $ref: "#/components/schemas/ImportCandidate" } } + errors: + type: array + items: + type: object + required: [source, message] + properties: + source: { type: string } + message: { type: string } + "400": { description: Invalid input } + /api/v1/session-imports/preview: + post: + description: Parse and retain an immutable read-only snapshot for 15 minutes (subject to bounded cache eviction and host restart). Does not create a session, start an agent or invoke a model. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [candidateId] + properties: + candidateId: { type: string } + responses: + "200": + description: Snapshot fingerprint, bounded excerpt and compatibility warnings + content: + application/json: + schema: { $ref: "#/components/schemas/ImportPreview" } + "400": { description: Unsupported or invalid source } + "409": { description: Candidate expired or source changed } + /api/v1/session-imports: + get: + description: List persisted import jobs. Previous-host unfinished jobs are marked interrupted. + responses: + "200": + description: Job history + content: + application/json: + schema: + type: object + properties: + jobs: { type: array, items: { $ref: "#/components/schemas/ImportJob" } } + post: + description: Import the exact retained preview snapshots, not later source changes, as new native sessions. Missing or expired previews require a new preview (409). Submitted snapshots are pinned until settlement. Identical source snapshots are skipped; newly previewed changed snapshots create new copies. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [selections] + properties: + selections: + type: array + minItems: 1 + maxItems: 100 + items: + type: object + required: [candidateId, fingerprint, cwd] + properties: + candidateId: { type: string } + fingerprint: { type: string, pattern: "^[a-f0-9]{64}$" } + cwd: { type: string, description: Existing absolute destination project directory } + responses: + "202": + description: Accepted job; poll the job resource for per-session results + content: + application/json: + schema: { $ref: "#/components/schemas/ImportJob" } + "400": { description: Invalid selection } + /api/v1/session-imports/{jobId}: + parameters: + - { name: jobId, in: path, required: true, schema: { type: string, format: uuid } } + get: + responses: + "200": + description: Current or persisted job + content: + application/json: + schema: { $ref: "#/components/schemas/ImportJob" } + "404": { description: Unknown job } + delete: + description: Request cancellation between sessions. Already published sessions remain; poll until the job settles. + responses: + "200": + description: Current job + content: + application/json: + schema: { $ref: "#/components/schemas/ImportJob" } + "404": { description: Unknown job } /api/v1/terminals: get: description: List Host-owned terminal metadata for a known workspace. Requires the Host cookie and allowed Origin; never starts a shell. @@ -1612,6 +1727,65 @@ paths: "200": { description: Workspace file contents } components: schemas: + ImportCandidate: + type: object + required: [id, source, sessionId, title, cwd, modified, archived, version] + properties: + id: { type: string } + source: { type: string, enum: [codex, claude-code] } + sessionId: { type: string } + title: + type: string + description: Readable bounded scan title; explicit source title, first user text, or project/source plus ID fallback. Preview may refine a fallback. + cwd: { type: string, description: Source project directory from discovered metadata } + sourcePath: { type: string, description: Absolute source JSONL path on the host } + modified: { type: string, format: date-time } + archived: { type: boolean } + version: { type: string } + ImportPreview: + allOf: + - { $ref: "#/components/schemas/ImportCandidate" } + - type: object + required: [fingerprint, messageCount, toolCount, warnings, excerpt] + properties: + fingerprint: { type: string } + messageCount: { type: integer } + toolCount: { type: integer } + excerpt: { type: string, maxLength: 12000 } + warnings: + type: array + items: + type: object + required: [code, count] + properties: + code: { type: string } + count: { type: integer } + ImportJob: + type: object + required: [id, createdAt, status, total, results] + properties: + id: { type: string, format: uuid } + createdAt: { type: string, format: date-time } + status: { type: string, enum: [running, completed, cancelled, interrupted] } + total: { type: integer } + results: + type: array + items: + type: object + required: [candidateId, status] + properties: + candidateId: { type: string } + source: { type: string, enum: [codex, claude-code], description: Source client; legacy recovery uses validated provenance only } + sourcePath: { type: string, description: Absolute source JSONL path on the host when known } + title: { type: string, description: Reviewed title or bounded native-header recovery for older results; absent when unknown } + cwd: + type: string + description: Selected absolute destination for imported, skipped or failed selections. Older results may recover native header cwd; absent when unknown or for batch-level journal failures. + status: { type: string, enum: [imported, skipped, failed] } + sessionId: { type: string } + path: { type: string } + error: { type: string } + warning: { type: string, description: "Session publication succeeded, but a secondary notification failed" } TerminalInfo: type: object required: [id, cwd, title, shell, status, cols, rows] diff --git a/apps/web/src/runtime.ts b/apps/web/src/runtime.ts index fed0ee20..16c8d8b5 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 bf0d5ea9..361db039 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"; @@ -259,6 +262,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]); @@ -671,6 +679,63 @@ 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", "customRoot"]) + 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({ + customRoot: typeof input.customRoot === "string" ? input.customRoot : undefined, + 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); } @@ -992,6 +1057,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 d245f1aa..684f42cf 100644 --- a/apps/web/test/e2e/web.e2e.mjs +++ b/apps/web/test/e2e/web.e2e.mjs @@ -250,6 +250,181 @@ 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"); + if ($('[data-import-app]')) throw new Error('Source cards must not exist before scanning'); + $('#importCustomToggle').click(); + if ($$('.session-import-custom-form input').length !== 1 || $('#importCustomPath').value !== '') throw new Error('Custom scan must have one blank path'); + $('#importCustomPath').value = ${JSON.stringify(base)}; + $('#importCustomPath').dispatchEvent(new Event('input')); + await new Promise(resolve => setTimeout(resolve, 50)); + const archived = $("#importIncludeArchived").checked; + $("#importCustomScan").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"] }) + const grouping = await browser.run(` + const groups = $$('[data-import-app]'); + const codex = $('[data-import-app="codex"]'); + const titles = $$('.session-import-section-head h1, .session-import-section-head h2').map(node => node.textContent); + const pathVisible = codex.textContent.includes(${JSON.stringify(sourcePath)}); + codex.querySelector('summary').click(); + const collapsed = !codex.open; + const search = $('.session-import-toolbar input[type="search"]'); + search.value = 'rollout-migration.jsonl'; + search.dispatchEvent(new Event('input')); + await until(() => $$('.session-import-row').length === 1, 'source path search'); + const staysCollapsed = !codex.open; + codex.querySelector('summary').click(); + const expanded = codex.open; + search.value = 'no-matching-migration-session'; + search.dispatchEvent(new Event('input')); + await until(() => $$('.session-import-row').length === 0, 'empty search'); + const emptyText = codex.querySelector('.empty-list').textContent; + search.value = ''; + search.dispatchEvent(new Event('input')); + await until(() => $$('.session-import-row').length === 2, 'cleared search'); + return { apps: groups.map(node => node.dataset.importApp), titles, pathVisible, collapsed, staysCollapsed, expanded, emptyText }; + `) + assert.deepEqual(grouping.apps, ["codex", "claude-code"]) + assert.equal(grouping.titles.length, 2) + assert.equal(grouping.pathVisible, false) + assert.equal(grouping.collapsed, true) + assert.equal(grouping.staysCollapsed, true) + assert.equal(grouping.expanded, true) + assert.match(grouping.emptyText, /没有匹配|No matching/) + await browser.run(` + await until(() => $$('.session-import-report').length === 2 && !$('#importScan').disabled, 'automatic preview statistics'); + if ($('.session-import-excerpt')) throw new Error('Preview body must not appear in list'); + if ($('#importSelectAll')) throw new Error('Global select all must be absent'); + $('[data-import-select="codex"]').click(); + await until(() => $('[data-import-source="codex"] input[type="checkbox"]').checked, 'app selected'); + const selectedRow = $('[data-import-source="codex"]'); + const unselectedRow = $('[data-import-source="claude-code"]'); + await until(() => getComputedStyle(selectedRow).backgroundColor !== getComputedStyle(unselectedRow).backgroundColor, 'selected row highlight'); + if (getComputedStyle($('.session-import-toolbar .search-input')).borderTopStyle !== 'solid') throw new Error('Search input has no border'); + if ($('[data-import-source="claude-code"] input[type="checkbox"]').checked) throw new Error('App select leaked'); + const projectSelect = $('[data-import-app="claude-code"] .session-import-select-group[data-import-select^="claude-code:"]'); + projectSelect.click(); + await until(() => $('[data-import-source="claude-code"] input[type="checkbox"]').checked, 'project selected'); + if (!$('#importSourcesTitle').parentElement.contains($('#importConfirm'))) throw new Error('Import is not in header'); + if ($('.session-import-row .session-import-path')) throw new Error('Session paths visible'); + if ($('[data-import-project] summary .session-import-project-path')) throw new Error('Project path must be hidden'); + if (!/已选.*1|1.*selected/i.test($('[data-import-app="codex"] > summary').textContent)) throw new Error('App selected count missing'); + if (!/已选.*1|1.*selected/i.test($('[data-import-app="claude-code"] [data-import-project] > summary').textContent)) throw new Error('Project selected count missing'); + if ($('[data-import-app="codex"] input:not([type="checkbox"])')) throw new Error('Scanned path must not be editable'); + if ($('[data-import-app="codex"] > summary .session-import-root').textContent !== ${JSON.stringify(base)}) throw new Error('Resolved root missing'); + $('#importCustomToggle').click(); + await until(() => !$("#importConfirm").disabled, "preview completion"); + if ($('.session-import-target') || $('#importReviewSelected')) throw new Error('Obsolete target or review controls'); + const button = $('[data-import-source="codex"] button'); + button.click(); + await until(() => $('.session-import-preview-popover'), 'floating preview'); + await until(() => !button.disabled, 'preview check complete'); + if (!$('.session-import-preview-popover').textContent.includes('Migration acceptance')) throw new Error('Missing preview content'); + const panel = $('.session-import-preview-popover'); + const rect = panel.getBoundingClientRect(); + const top = document.elementFromPoint(Math.min(innerWidth - 1, rect.left + rect.width / 2), Math.min(innerHeight - 1, rect.top + 20)); + if (!panel.contains(top)) throw new Error('Preview is covered by settings'); + button.click(); + await until(() => !$('.session-import-preview-popover'), 'closed preview'); + return true; + `) + await browser.send("Emulation.setDeviceMetricsOverride", { + width: 390, height: 844, deviceScaleFactor: 1, mobile: false, + }) + try { + const layout = await browser.run(` + await new Promise(resolve => requestAnimationFrame(resolve)); + const content = $(".settings-content"); + const buttons = [$("#importConfirm")].map(node => node.getBoundingClientRect()); + return { + contentWidth: content.getBoundingClientRect().width, + overflowing: content.scrollWidth > content.clientWidth, + offenders: [...content.querySelectorAll('*')].filter(node => node.getBoundingClientRect().right > innerWidth).slice(0, 12).map(node => ({class: node.className, width: node.getBoundingClientRect().width})), + buttonsFit: buttons.every(rect => rect.left >= 0 && rect.right <= innerWidth), + }; + `) + assert.ok(layout.contentWidth >= 280, JSON.stringify(layout)) + assert.equal(layout.overflowing, false, JSON.stringify(layout)) + assert.equal(layout.buttonsFit, true) + } finally { + await browser.send("Emulation.setDeviceMetricsOverride", { + width: 1280, height: 900, deviceScaleFactor: 1, mobile: false, + }) + } + const updatedInput = input + [ + { type: "event_msg", timestamp, payload: { type: "token_count", info: {} } }, + { type: "response_item", timestamp, payload: { type: "message", role: "user", content: [{ type: "input_text", text: "Unreviewed source append" }] } }, + ].map(JSON.stringify).join("\n") + "\n" + await writeFile(sourcePath, updatedInput) + await browser.run(` + $("#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; + const history = $$('[data-import-history-app]'); + if (history.length !== 2 || history.some(group => group.querySelectorAll('.session-import-result').length !== 1)) { + throw new Error('Import history is not grouped by app'); + } + if ($('.session-import-result .session-import-path')) throw new Error('History session paths visible'); + history[0].querySelector('summary').click(); + if (history[0].open) throw new Error('Import history did not collapse'); + history[0].querySelector('summary').click(); + return true; + `) + const imported = await browser.run(` + const job = window.migrationJob; + const sessions = (await (await fetch("/api/v1/sessions")).json()).sessions; + const codex = sessions.find(session => job.results.some(result => result.sessionId === session.id) && session.name.startsWith("[codex]")); + if (!codex) throw new Error("Codex import missing from native sessions"); + const snapshot = await (await fetch("/api/v1/sessions/" + codex.id)).json(); + if (!snapshot.entries.some(entry => entry.imported)) throw new Error("Missing source provenance"); + if (JSON.stringify(snapshot.entries).includes("Unreviewed source append")) throw new Error("Imported unreviewed content"); + const result = job.results.find(result => result.sessionId === codex.id); + // Grouped history renders rows per app, so select by the imported session id, not DOM order. + document.querySelector('.session-import-result[data-import-result="' + codex.id + '"] button').click(); + await until(() => $("#settingsView").hidden, "opened native import"); + await until(() => document.body.textContent.includes("Migration acceptance quokkabridge"), "imported history"); + return { id: codex.id, hasUnknown: !!$("[data-tool-state='unknown']") }; + `) + assert.equal(imported.hasUnknown, true) + assert.equal(await readFile(sourcePath, "utf8"), updatedInput) + await browser.send("Page.reload") + await browser.run(`await until(() => document.body.textContent.includes("Migration acceptance quokkabridge"), "reopened after refresh");`) + const search = await browser.run(` + const result = await (await fetch("/api/v1/search?q=quokkabridge")).json(); + return result.hits.some(hit => hit.sessionId === ${JSON.stringify(imported.id)}); + `) + assert.equal(search, true) +}) + let base let workspace let extra @@ -2123,3 +2298,21 @@ test("a subagent or workflow call opens its child agents' transcripts read-only `) assert.deepEqual(refused, [404, 404]) }) + + +test("new conversation offers a lightweight shortcut to session migration", { skip }, async () => { + const report = await browser.run(` + if (!$("#settingsView").hidden) $("#settingsBack").click(); + await until(() => $("#newSession"), "new conversation"); + $("#newSession").click(); + const link = await until(() => $("#emptySessionImport"), "empty import shortcut"); + const text = link.textContent; + link.click(); + await until(() => !$("#settingsView").hidden && $("[data-page='migration']"), "migration settings"); + const migration = !!$("#importScan"); + $("#settingsBack").click(); + return { text, migration }; + `); + assert.equal(report.migration, true); + assert.match(report.text, /Claude Code \/ Codex/); +}); diff --git a/packages/coding-agent/AGENTS.md b/packages/coding-agent/AGENTS.md index b41ac58d..691450c3 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..870b72df --- /dev/null +++ b/packages/coding-agent/docs/session-import.md @@ -0,0 +1,116 @@ +# 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 and selected destination `cwd` survive host restart. Every +per-selection imported, skipped or failed result includes the selected absolute +`cwd`, even when that directory could not be opened; it is not the source project +or a claim that a skipped existing session has that cwd. A batch-level journal +failure may have no destination. Persisted cwd values must be absolute strings +without NUL characters; invalid values reject the journal. + +Older journals can recover missing cwd and title from at most the first 1 MiB of +a native session, only when its resolved path is inside `sessionRoot/imported`. +Recovery uses the native session header for cwd, `session_info` name or first user +text for title, and validated entry provenance for source. Existing metadata is +preserved. Missing, inaccessible, escaped or out-of-window metadata remains +unknown; recovery neither opens a live session nor rewrites native history. +If the index callback fails after publication, the result remains imported (or +skipped), with a warning rather than a false failure; retrying also re-notifies +the index. + +## 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 inspects the first 1 MiB for readable titles without +full adapter parsing, attachment loading, preview caching or source writes. It +uses explicit Claude `custom-title`/`summary` records or Codex metadata `title` / +`thread_name` (the Codex title index takes precedence), otherwise normalized first +user text, then a project/source plus ID fallback. Titles are capped at 120 +characters. Fallback scan titles do not override the fully parsed preview title. +Discovery decodes only complete JSONL records. Until identity metadata is found, +it retains the existing complete-record read behavior within the 128 MiB source +cap, including metadata-bearing records over 1 MiB. A final newline is not +required. Other titles beyond the window require preview. +Symlinks are not traversed during discovery. +Codex date subdirectories can be selected directly. A selected path inside +`archived_sessions` is still excluded unless archive inclusion is enabled. + +Web endpoints are specified in [the host OpenAPI contract](../../../apps/web/openapi.yaml). +They use the host's existing cookie and Origin boundary. The browser always +scans the **host** machine, not a remote browser device. diff --git a/packages/coding-agent/src/core/session-import/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..075821ab --- /dev/null +++ b/packages/coding-agent/src/core/session-import/service.ts @@ -0,0 +1,818 @@ +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"; +import { isImportProvenance } from "./types.ts"; + +const MAX_SOURCE_BYTES = 128 * 1024 * 1024; +const HEADER_BYTES = 1024 * 1024; + +function readableTitle(value: unknown): string { + return typeof value === "string" ? value.replace(/\s+/gu, " ").trim().slice(0, 120) : ""; +} + +function userTitle(content: unknown): string { + if (typeof content === "string") return readableTitle(content); + if (!Array.isArray(content)) return ""; + return readableTitle( + content + .filter((block) => ["text", "input_text"].includes(String(record(block).type))) + .map((block) => record(block).text) + .filter((text) => typeof text === "string") + .join(" "), + ); +} + +function within(root: string, path: string): boolean { + const rel = relative(root, path); + return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel); +} +const MAX_CANDIDATES = 5000; +const MAX_DEPTH = 16; +const MAX_PREVIEW_BYTES = 256 * 1024 * 1024; +const PREVIEW_TTL_MS = 15 * 60 * 1000; + +async function sourceAttachments(input: string, root: string): Promise<{ input: string; digest: string }> { + const fingerprints: string[] = []; + let total = 0; + const localImage = async (value: string): Promise<{ data: string; mime: string } | undefined> => { + if (!value || /^data:|^https?:/u.test(value)) return undefined; + try { + const path = await realpath( + value.startsWith("file:") ? fileURLToPath(value) : isAbsolute(value) ? value : join(root, value), + ); + const rel = relative(root, path); + if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return undefined; + const info = await stat(path); + if (!info.isFile() || info.size > 20 * 1024 * 1024 || total + info.size > 32 * 1024 * 1024) return undefined; + const data = await readFile(path); + const after = await stat(path); + if (info.size !== after.size || info.mtimeMs !== after.mtimeMs) + throw new SessionImportError("Attachment changed; preview again", 409); + const mime = data.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) + ? "image/png" + : data[0] === 255 && data[1] === 216 + ? "image/jpeg" + : data.subarray(0, 3).toString() === "GIF" + ? "image/gif" + : data.subarray(0, 4).toString() === "RIFF" && data.subarray(8, 12).toString() === "WEBP" + ? "image/webp" + : undefined; + if (!mime) return undefined; + total += data.length; + fingerprints.push(`${rel}:${createHash("sha256").update(data).digest("hex")}`); + return { data: data.toString("base64"), mime }; + } catch (error) { + if (error instanceof SessionImportError) throw error; + if (["ENOENT", "EACCES", "EPERM", "EINVAL"].includes(String(record(error).code))) return undefined; + throw error; + } + }; + const visit = async (value: unknown, depth: number): Promise => { + if (depth > 32) throw new SessionImportError("Source content nesting limit exceeded"); + if (Array.isArray(value)) { + for (const child of value) await visit(child, depth + 1); + return; + } + const r = record(value); + if (r.type === "input_image" && typeof r.image_url === "string" && !r.image_url.startsWith("data:")) { + const image = await localImage(r.image_url); + if (image) r.image_url = `data:${image.mime};base64,${image.data}`; + } + if (r.type === "image" && record(r.source).type === "file" && typeof record(r.source).path === "string") { + const image = await localImage(String(record(r.source).path)); + if (image) r.source = { type: "base64", media_type: image.mime, data: image.data }; + } + for (const child of Object.values(r)) if (child && typeof child === "object") await visit(child, depth + 1); + }; + const lines: string[] = []; + for (const line of input.split(/\r?\n/u)) { + if (!line.trim()) continue; + let value: unknown; + try { + value = JSON.parse(line); + } catch { + throw new SessionImportError("Invalid JSONL snapshot; wait for the source to finish writing"); + } + await visit(value, 0); + lines.push(JSON.stringify(value)); + } + return { input: lines.join("\n"), digest: fingerprints.join("\n") }; +} + +interface CandidateFile { + candidate: ImportCandidate; + path: string; + root: string; +} + +interface ReviewedSnapshot { + data: string; + bytes: number; + expires: number; + users: number; +} + +export interface ImportScanOptions { + customRoot?: string; + codexRoot?: string; + claudeRoot?: string; + includeArchived?: boolean; +} + +export interface ImportScanResult { + roots: Partial>; + candidates: ImportCandidate[]; + errors: { source: ImportSource; message: string }[]; +} + +export class SessionImportError extends Error { + readonly status: number; + constructor(message: string, status = 400) { + super(message); + this.status = status; + } +} + +/** A local, offline migration service. Source files are opened read-only, never as live sessions. */ +export class SessionImportService { + private readonly agentDir: string; + private readonly sessionRoot: string; + private readonly onPersisted?: (event: SessionFilePersistedEvent) => void; + private readonly candidates = new Map(); + private readonly explicitTitles = new WeakSet(); + private readonly jobs = new Map(); + private readonly running = new Map>(); + private readonly cancelled = new Set(); + private readonly previews = new Map(); + private queue: Promise = Promise.resolve(); + private closed = false; + + constructor(options: { + agentDir: string; + sessionRoot?: string; + onPersisted?: (event: SessionFilePersistedEvent) => void; + }) { + this.agentDir = resolve(options.agentDir); + this.sessionRoot = resolve(options.sessionRoot ?? join(this.agentDir, "sessions")); + this.onPersisted = options.onPersisted; + } + + async scan(options: ImportScanOptions = {}): Promise { + if (this.closed) throw new SessionImportError("Importer is closed", 503); + const result: ImportScanResult = { roots: {}, candidates: [], errors: [] }; + const roots: [ImportSource, string][] = [ + [ + "codex", + options.customRoot?.trim() || + options.codexRoot?.trim() || + process.env.CODEX_HOME?.trim() || + join(homedir(), ".codex"), + ], + [ + "claude-code", + options.customRoot?.trim() || + options.claudeRoot?.trim() || + join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude"), "projects"), + ], + ]; + // 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 (!options.customRoot) result.roots[source] = root; + if (root.split(sep).includes("archived_sessions") && !options.includeArchived) continue; + const rootEntries = await readdir(root, { withFileTypes: true }); + const codexHome = + source === "codex" && + rootEntries.some( + (entry) => + (entry.isDirectory() && ["sessions", "archived_sessions"].includes(entry.name)) || + entry.name === "session_index.jsonl", + ); + const titles = new Map(); + if (source === "codex") { + const index = join(root, "session_index.jsonl"); + try { + const info = await stat(index); + if (info.size > 32 * 1024 * 1024) throw new Error("Source title index exceeds 32 MiB"); + for (const line of (await readFile(index, "utf8")).split(/\r?\n/u)) { + if (!line.trim()) continue; + const entry = record(JSON.parse(line)); + if (typeof entry.id === "string" && typeof entry.thread_name === "string") + titles.set(entry.id, entry.thread_name.slice(0, 120)); + } + } catch (error) { + if (record(error).code !== "ENOENT") + result.errors.push({ + source, + message: `Title index: ${error instanceof Error ? error.message : String(error)}`, + }); + } + } + const walk = async (dir: string, depth: number): Promise => { + if (depth > MAX_DEPTH) throw new Error("Source directory depth limit exceeded"); + const files = await readdir(dir, { withFileTypes: true }); + for (const file of files) { + if (this.closed) throw new Error("Importer is closed"); + if (file.isSymbolicLink()) continue; + const archived = file.name === "archived_sessions"; + if (archived && !options.includeArchived) continue; + if ( + ["node_modules", ".git", "attachments", "worktrees", "backups", "backups_state"].includes( + file.name, + ) + ) + continue; + const path = join(dir, file.name); + if (file.isDirectory()) { + if ( + source === "codex" && + codexHome && + dir === root && + !["sessions", "archived_sessions"].includes(file.name) + ) + continue; + await walk(path, depth + 1); + } else if ( + file.isFile() && + file.name.endsWith(".jsonl") && + (!!options.customRoot || source !== "codex" || file.name.startsWith("rollout-")) + ) { + if (candidates.size >= MAX_CANDIDATES) + throw new Error("Session limit exceeded; select a narrower source directory"); + try { + const candidate = await this.discover(source, path, root, !!options.customRoot); + if (!candidate || (candidate.archived && !options.includeArchived)) continue; + const indexedTitle = readableTitle(titles.get(candidate.sessionId)); + if (indexedTitle) { + candidate.title = indexedTitle; + this.explicitTitles.add(candidate); + } + candidates.set(candidate.id, { candidate, path, root }); + result.candidates.push(candidate); + result.roots[source] = root; + } catch (error) { + result.errors.push({ + source, + message: `${file.name}: ${error instanceof Error ? error.message : String(error)}`, + }); + } + } + } + }; + await walk(root, 0); + } catch (error) { + // An absent optional client is normal; permission and parse errors must remain visible. + if (record(error).code !== "ENOENT") + result.errors.push({ source, message: error instanceof Error ? error.message : String(error) }); + } + } + this.candidates.clear(); + for (const [id, candidate] of candidates) this.candidates.set(id, candidate); + result.candidates.sort((a, b) => b.modified.localeCompare(a.modified)); + return result; + } + + private async discover( + source: ImportSource, + path: string, + root: string, + allowOtherFormat = false, + ): Promise { + const handle = await open(path, "r"); + try { + const info = await handle.stat(); + if (!info.isFile() || info.size > MAX_SOURCE_BYTES) + throw new Error("Source is not a regular file or exceeds 128 MiB"); + let metadata: Record | undefined; + let title = ""; + let firstUser = ""; + let pending: Buffer[] = []; + const inspect = (bytes: Buffer): void => { + const line = bytes.toString("utf8"); + if (!line.trim()) return; + let value: unknown; + try { + value = JSON.parse(line); + } catch (error) { + // Only preview validates the transcript after discovery has established its identity. + if (!metadata) throw error; + return; + } + const r = record(value); + if (source === "codex") { + const p = record(r.payload); + if (r.type === "session_meta") { + metadata ??= p; + title = readableTitle(p.title) || readableTitle(p.thread_name) || title; + } + if (!firstUser && r.type === "response_item" && p.type === "message" && p.role === "user") + firstUser = userTitle(p.content); + if (!firstUser && r.type === "event_msg" && p.type === "user_message") + firstUser = readableTitle(p.message); + } else { + if (typeof r.sessionId === "string") metadata ??= r; + if (r.isSidechain === true || (metadata && r.sessionId && r.sessionId !== metadata.sessionId)) return; + if (r.type === "custom-title" || r.type === "summary") + title = readableTitle(r.customTitle) || readableTitle(r.summary) || title; + else title = readableTitle(r.customTitle) || title; + if (!firstUser && r.type === "user") firstUser = userTitle(record(r.message).content); + } + }; + // Title reads stop at 1 MiB. Metadata may share a larger record with message/image content; + // retain complete-record discovery up to the source cap until identity is established. + let position = 0; + while (position < info.size && (!metadata || position < HEADER_BYTES)) { + const limit = metadata ? Math.min(info.size, HEADER_BYTES) : info.size; + const buffer = Buffer.alloc(Math.min(limit - position, 64 * 1024)); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, position); + if (!bytesRead) break; + position += bytesRead; + const chunk = buffer.subarray(0, bytesRead); + let start = 0; + for (let end = chunk.indexOf(10); end !== -1; end = chunk.indexOf(10, start)) { + pending.push(chunk.subarray(start, end)); + inspect(Buffer.concat(pending)); + pending = []; + start = end + 1; + if (metadata && position - chunk.length + start >= HEADER_BYTES) { + start = chunk.length; + break; + } + } + if (start < chunk.length) pending.push(chunk.subarray(start)); + } + if (position === info.size && pending.length) inspect(Buffer.concat(pending)); + if (!metadata) { + if (allowOtherFormat) return undefined; + throw new Error("Session metadata is missing from the bounded header"); + } + if ( + metadata.isSidechain === true || + metadata.parentAgentId || + record(metadata.source).subagent || + record(metadata.thread_source).subagent + ) + return undefined; + const sessionId = source === "codex" ? metadata.id : metadata.sessionId; + if (typeof sessionId !== "string" || !sessionId) throw new Error("Invalid source session ID"); + const cwd = typeof metadata.cwd === "string" ? metadata.cwd : ""; + const archived = + relative(root, path).split(sep).includes("archived_sessions") || + root.split(sep).includes("archived_sessions") || + metadata.archived === true; + const candidate: ImportCandidate = { + id: randomUUID(), + sourcePath: path, + source, + sessionId, + title: title || firstUser || `${basename(cwd) || source} · ${sessionId.slice(0, 8)}`, + cwd, + modified: info.mtime.toISOString(), + archived, + version: + typeof metadata.cli_version === "string" + ? metadata.cli_version + : typeof metadata.version === "string" + ? metadata.version + : "unknown", + }; + if (title) this.explicitTitles.add(candidate); + return candidate; + } finally { + await handle.close(); + } + } + + private async snapshot( + id: string, + file = this.candidates.get(id), + ): Promise<{ parsed: ParsedImport; preview: ImportPreview }> { + if (!file) throw new SessionImportError("Candidate expired; scan again", 409); + const path = await realpath(file.path); + const rel = relative(file.root, path); + if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel) || (await lstat(file.path)).isSymbolicLink()) + throw new SessionImportError("Source escaped its selected directory"); + const handle = await open(path, "r"); + let input: string; + try { + const before = await handle.stat(); + if (!before.isFile() || before.size > MAX_SOURCE_BYTES) throw new SessionImportError("Source exceeds 128 MiB"); + input = await handle.readFile("utf8"); + const after = await handle.stat(); + const current = await stat(path); + if ( + before.size !== after.size || + before.mtimeMs !== after.mtimeMs || + before.ino !== current.ino || + current.size !== after.size || + current.mtimeMs !== after.mtimeMs + ) + throw new SessionImportError("Source changed while reading; preview again", 409); + } finally { + await handle.close(); + } + const attachments = await sourceAttachments(input, file.root); + const parsed = parseForeignSession(file.candidate.source, attachments.input); + if (parsed.child || parsed.sessionId !== file.candidate.sessionId) + throw new SessionImportError("Source identity changed", 409); + if (parsed.archived && !file.candidate.archived) + throw new SessionImportError("Source was archived; scan again", 409); + const messages = parsed.entries.filter((e) => e.type === "message"); + return { + parsed, + preview: { + ...file.candidate, + title: this.explicitTitles.has(file.candidate) + ? file.candidate.title + : parsed.title || file.candidate.title, + fingerprint: createHash("sha256") + .update(input) + .update(attachments.digest ? `\0${attachments.digest}` : "") + .digest("hex"), + messageCount: messages.length, + toolCount: messages.reduce( + (n, e) => + n + + (e.message.role === "assistant" ? e.message.content.filter((b) => b.type === "toolCall").length : 0), + 0, + ), + warnings: parsed.warnings, + excerpt: messages + .flatMap((e) => { + const m = e.message; + if (!("content" in m)) return []; + const body = + typeof m.content === "string" + ? m.content + : m.content + .filter((b) => b.type === "text") + .map((b) => b.text) + .join("\n"); + return body ? [`${m.role}: ${body}`] : []; + }) + .join("\n\n") + .slice(0, 12000), + }, + }; + } + + async preview(id: string): Promise { + if (this.closed) throw new SessionImportError("Importer is closed", 503); + const { parsed, preview } = await this.snapshot(id); + if (this.closed) throw new SessionImportError("Importer is closed", 503); + const key = `${id}:${preview.fingerprint}`; + const existing = this.previews.get(key); + if (existing) { + existing.expires = Date.now() + PREVIEW_TTL_MS; + return preview; + } + const document: ImportDocument = { preview, entries: parsed.entries }; + const data = JSON.stringify(document); + const bytes = data.length * 2; + if (bytes > MAX_PREVIEW_BYTES) throw new SessionImportError("Preview snapshot exceeds cache limit", 413); + this.prunePreviews(); + let used = [...this.previews.values()].reduce((sum, entry) => sum + entry.bytes, 0); + for (const [oldKey, entry] of this.previews) { + if (used + bytes <= MAX_PREVIEW_BYTES) break; + if (entry.users) continue; + this.previews.delete(oldKey); + used -= entry.bytes; + } + if (used + bytes > MAX_PREVIEW_BYTES) + throw new SessionImportError("Preview cache is busy; wait for current imports to finish", 409); + this.previews.set(key, { data, bytes, expires: Date.now() + PREVIEW_TTL_MS, users: 0 }); + return preview; + } + + private prunePreviews(): void { + for (const [key, entry] of this.previews) { + if (!entry.users && entry.expires <= Date.now()) this.previews.delete(key); + } + } + + private async saveJob(job: ImportJob): Promise { + const dir = join(this.agentDir, "session-imports"); + await mkdir(dir, { recursive: true }); + const path = join(dir, `${job.id}.json`); + const temporary = `${path}.${randomUUID()}.tmp`; + try { + await writeFile(temporary, JSON.stringify(job), { flag: "wx", mode: 0o600 }); + await rename(temporary, path); + } finally { + await unlink(temporary).catch((error: unknown) => { + // A successful rename has already removed the temporary directory entry. + if (record(error).code !== "ENOENT") throw error; + }); + } + } + + async listJobs(): Promise { + const dir = join(this.agentDir, "session-imports"); + let names: string[]; + try { + names = await readdir(dir); + } catch (error) { + if (record(error).code === "ENOENT") return []; + throw error; + } + const jobs: ImportJob[] = []; + for (const name of names.filter((n) => /^[a-f0-9-]{36}\.json$/u.test(n))) { + const id = name.slice(0, -5); + const job = await this.getJob(id); + jobs.push(job); + } + return jobs; + } + + async getJob(id: string): Promise { + if (!/^[a-f0-9-]{36}$/u.test(id)) throw new SessionImportError("Invalid job ID"); + const active = this.jobs.get(id); + if (active) return structuredClone(active); + let value: unknown; + try { + value = JSON.parse(await readFile(join(this.agentDir, "session-imports", `${id}.json`), "utf8")); + } catch (error) { + if (record(error).code === "ENOENT") throw new SessionImportError("Job not found", 404); + throw error; + } + const r = record(value); + if ( + r.id !== id || + !["running", "completed", "cancelled", "interrupted"].includes(String(r.status)) || + !Number.isSafeInteger(r.total) || + Number(r.total) < 1 || + Number(r.total) > 100 || + !Array.isArray(r.results) + ) + throw new SessionImportError("Invalid persisted import job", 500); + // Only this service owns live jobs; a previous host's unfinished batch cannot still be running. + for (const value of r.results) { + const result = record(value); + if ( + typeof result.candidateId !== "string" || + !["imported", "skipped", "failed"].includes(String(result.status)) || + (result.source !== undefined && result.source !== "codex" && result.source !== "claude-code") || + (result.sourcePath !== undefined && typeof result.sourcePath !== "string") || + (result.sessionId !== undefined && typeof result.sessionId !== "string") || + (result.path !== undefined && typeof result.path !== "string") || + (result.error !== undefined && typeof result.error !== "string") || + (result.warning !== undefined && typeof result.warning !== "string") || + (result.cwd !== undefined && + (typeof result.cwd !== "string" || !isAbsolute(result.cwd) || result.cwd.includes("\0"))) || + (result.title !== undefined && typeof result.title !== "string") + ) + throw new SessionImportError("Invalid persisted import result", 500); + } + if (typeof r.createdAt !== "string" || !Number.isFinite(Date.parse(r.createdAt))) + throw new SessionImportError("Invalid persisted import date", 500); + // The envelope and every result were validated at this persistence boundary. + const job = value as ImportJob; + for (const result of job.results) { + if (!result.path || (result.source && result.cwd && result.title)) continue; + try { + const root = await realpath(join(this.sessionRoot, "imported")); + const path = await realpath(result.path); + if (!within(root, path)) continue; + const handle = await open(path, "r"); + try { + const info = await handle.stat(); + if (!info.isFile()) continue; + const buffer = Buffer.alloc(Math.min(info.size, HEADER_BYTES)); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + const bytes = buffer.subarray(0, bytesRead); + // Discard an incomplete final record, except at the actual file end. + const complete = bytesRead === info.size ? bytes : bytes.subarray(0, bytes.lastIndexOf(10) + 1); + let firstUser = ""; + let name = ""; + for (const line of complete.toString("utf8").split(/\r?\n/u)) { + let value: unknown; + try { + value = JSON.parse(line); + } catch { + continue; + } + const entry = record(value); + if ( + entry.type === "session" && + typeof entry.cwd === "string" && + isAbsolute(entry.cwd) && + !entry.cwd.includes("\0") + ) + result.cwd ??= entry.cwd; + if (entry.type === "session_info") name = readableTitle(entry.name) || name; + const message = record(entry.message); + if (!firstUser && entry.type === "message" && message.role === "user") + firstUser = userTitle(message.content); + if (!result.source && isImportProvenance(entry.imported)) result.source = entry.imported.source; + } + if (!result.title) { + // Native imported names have a display prefix; journal titles do not. + const title = name.replace(/^\[(codex|claude-code)\] /u, "") || firstUser; + if (title) result.title = title; + } + } finally { + await handle.close(); + } + } catch { + // Deleted or inaccessible native sessions leave truthful unknown metadata in old journals. + } + } + if (job.status === "running") { + job.status = "interrupted"; + await this.saveJob(job); + } + return job; + } + + async start(selections: ImportSelection[]): Promise { + if (this.closed) throw new SessionImportError("Importer is closed", 503); + if (!selections.length || selections.length > 100) + throw new SessionImportError("Select between 1 and 100 sessions"); + this.prunePreviews(); + const submitted = selections.map((selection) => { + const file = this.candidates.get(selection.candidateId); + if (!file || !/^[a-f0-9]{64}$/u.test(selection.fingerprint) || !isAbsolute(selection.cwd)) + throw new SessionImportError("Invalid selection; preview and choose an absolute project directory"); + const snapshot = this.previews.get(`${selection.candidateId}:${selection.fingerprint}`); + if (!snapshot || snapshot.expires <= Date.now()) + throw new SessionImportError("Preview expired; preview the selected session again", 409); + return { selection: { ...selection }, file, snapshot }; + }); + const job: ImportJob = { + id: randomUUID(), + createdAt: new Date().toISOString(), + status: "running", + total: selections.length, + results: [], + }; + // Pin before the first await so scans, eviction and other jobs cannot replace reviewed content. + for (const { snapshot } of submitted) snapshot.users++; + try { + await this.saveJob(job); + } catch (error) { + for (const { snapshot } of submitted) snapshot.users--; + throw error; + } + this.jobs.set(job.id, job); + const run = this.queue + .then(async () => { + for (const { selection, file, snapshot } of submitted) { + if (this.cancelled.has(job.id) || this.closed) break; + let result: ImportResult; + try { + result = await this.commit(selection, snapshot); + } catch (error) { + result = { + candidateId: selection.candidateId, + source: file.candidate.source, + sourcePath: file.path, + title: file.candidate.title, + cwd: selection.cwd, + status: "failed", + error: error instanceof Error ? error.message : String(error), + }; + } + job.results.push(result); + await this.saveJob(job); + } + job.status = this.cancelled.has(job.id) || this.closed ? "cancelled" : "completed"; + await this.saveJob(job); + }) + .catch(async (error: unknown) => { + job.status = "interrupted"; + job.results.push({ + candidateId: "", + status: "failed", + error: `Import journal failed: ${error instanceof Error ? error.message : String(error)}`, + }); + // Keep the in-memory terminal failure available even if the disk journal is unavailable. + }) + .finally(() => { + for (const { snapshot } of submitted) snapshot.users--; + this.prunePreviews(); + this.running.delete(job.id); + this.cancelled.delete(job.id); + }); + this.queue = run; + this.running.set(job.id, run); + return structuredClone(job); + } + + async cancel(id: string): Promise { + const job = await this.getJob(id); + if (job.status === "running") this.cancelled.add(id); + return job; + } + + async close(): Promise { + this.closed = true; + await this.queue; + this.previews.clear(); + } + + private async commit(selection: ImportSelection, snapshot: ReviewedSnapshot): Promise { + // This immutable document was serialized by preview(), not supplied by the client or source. + const { entries: reviewedEntries, preview } = JSON.parse(snapshot.data) as ImportDocument; + const cwd = await realpath(selection.cwd); + if (!(await stat(cwd)).isDirectory()) throw new SessionImportError("Project directory does not exist"); + const hash = createHash("sha256") + .update(`${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, + source: preview.source, + sourcePath: preview.sourcePath, + title: preview.title, + cwd: selection.cwd, + status, + sessionId: id, + path, + warning, + }; + } +} diff --git a/packages/coding-agent/src/core/session-import/types.ts b/packages/coding-agent/src/core/session-import/types.ts new file mode 100644 index 00000000..48383a16 --- /dev/null +++ b/packages/coding-agent/src/core/session-import/types.ts @@ -0,0 +1,90 @@ +import type { FileEntry, SessionEntry } from "../session-manager.ts"; + +export type ImportSource = "codex" | "claude-code"; + +export interface ImportProvenance { + source: ImportSource; + sessionId: string; + itemId: string; + historicalUsage?: Record; +} + +export interface ImportCandidate { + id: string; + source: ImportSource; + sessionId: string; + title: string; + cwd: string; + modified: string; + archived: boolean; + version: string; + /** Absolute source JSONL path discovered by the offline scanner. */ + sourcePath?: string; +} + +export interface ImportWarning { + code: string; + count: number; +} + +export interface ImportPreview extends ImportCandidate { + fingerprint: string; + messageCount: number; + toolCount: number; + warnings: ImportWarning[]; + excerpt: string; +} + +export interface ParsedImport { + sessionId: string; + cwd: string; + title: string; + version: string; + archived: boolean; + child: boolean; + entries: SessionEntry[]; + warnings: ImportWarning[]; +} + +export interface ImportSelection { + candidateId: string; + fingerprint: string; + cwd: string; +} + +export interface ImportResult { + candidateId: string; + source?: ImportSource; + sourcePath?: string; + title?: string; + /** Selected absolute destination project directory, not the source cwd. */ + cwd?: string; + status: "imported" | "skipped" | "failed"; + sessionId?: string; + path?: string; + error?: string; + warning?: string; +} + +export interface ImportJob { + id: string; + createdAt: string; + status: "running" | "completed" | "cancelled" | "interrupted"; + total: number; + results: ImportResult[]; +} + +export interface ImportDocument { + preview: ImportPreview; + entries: FileEntry[]; +} + +export function 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 b1315b03..4990c98f 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..f5e968e0 --- /dev/null +++ b/packages/coding-agent/test/session-import.test.ts @@ -0,0 +1,967 @@ +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fauxAssistantMessage, fauxToolCall } from "@step-harness/providers"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { parseForeignSession } from "../src/core/session-import/parsers.ts"; +import { SessionImportService } from "../src/core/session-import/service.ts"; +import { buildSessionContext, SessionManager } from "../src/core/session-manager.ts"; +import { createWriteTool } from "../src/core/tools/write.ts"; +import { collectSessionUsage } from "../src/core/usage-totals.ts"; +import { createHarness } from "./suite/harness.ts"; + +const timestamp = "2026-10-22T00:00:00.000Z"; +const jsonl = (records: unknown[]) => `${records.map((r) => JSON.stringify(r)).join("\n")}\n`; +const meta = (cwd = "/project", id = "source-1") => ({ + type: "session_meta", + timestamp, + payload: { id, cwd, cli_version: "0.163.0-alpha.11", history_mode: "paginated" }, +}); +const item = (payload: unknown) => ({ type: "response_item", timestamp, payload }); +const user = (value: string, id?: string) => + item({ type: "message", role: "user", id, content: [{ type: "input_text", text: value }] }); +const reply = (value: string) => + item({ type: "message", role: "assistant", content: [{ type: "output_text", text: value }] }); + +const directories: string[] = []; +afterEach(async () => { + for (const dir of directories.splice(0)) await rm(dir, { recursive: true, force: true }); +}); + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "step-session-import-")); + directories.push(root); + const cwd = join(root, "project"); + const codexRoot = join(root, "codex"); + const claudeRoot = join(root, "claude"); + const 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("returns readable scan titles without previewing or validating the full transcript", async () => { + const f = await fixture(); + const claude = join(f.claudeRoot, "named.jsonl"); + await writeFile( + claude, + jsonl([ + { + type: "user", + sessionId: "named", + cwd: f.cwd, + message: { content: [{ type: "text", text: "First task" }] }, + }, + { type: "summary", sessionId: "named", summary: "Useful summary" }, + { type: "custom-title", sessionId: "named", customTitle: "Explicit name" }, + ]) + "malformed unfinished tail", + ); + try { + const scan = await f.service.scan(f); + expect(scan.errors).toEqual([]); + expect(scan.candidates.find((c) => c.source === "codex")?.title).toBe("Keep migration facts"); + expect(scan.candidates.find((c) => c.sessionId === "named")?.title).toBe("Explicit name"); + expect(await readdir(f.agentDir).catch(() => [])).toEqual([]); + expect(await readFile(claude, "utf8")).toContain("malformed unfinished tail"); + } finally { + await f.service.close(); + } + }); + + it("bounds title discovery but lets preview replace first-user fallbacks with parsed titles", async () => { + const f = await fixture(); + await writeFile( + join(f.claudeRoot, "late.jsonl"), + jsonl([ + { type: "user", uuid: "u", sessionId: "late", cwd: f.cwd, message: { content: "Early task" } }, + { type: "ignored", padding: "x".repeat(1024 * 1024) }, + { type: "custom-title", sessionId: "late", customTitle: "Late explicit title" }, + ]), + ); + try { + const c = (await f.service.scan(f)).candidates.find((c) => c.sessionId === "late"); + expect(c?.title).toBe("Early task"); + expect((await f.service.preview(c?.id ?? "")).title).toBe("Late explicit title"); + } finally { + await f.service.close(); + } + }); + + it("prefers reliable Codex index and metadata titles over first user text", async () => { + const f = await fixture(); + await writeFile( + f.source, + jsonl([{ ...meta(f.cwd), payload: { ...meta(f.cwd).payload, title: "Metadata title" } }, user("Task text")]), + ); + try { + let c = (await f.service.scan(f)).candidates[0]; + expect(c.title).toBe("Metadata title"); + expect((await f.service.preview(c.id)).title).toBe("Metadata title"); + await writeFile( + join(f.codexRoot, "session_index.jsonl"), + jsonl([{ id: c.sessionId, thread_name: "Index title" }]), + ); + c = (await f.service.scan(f)).candidates[0]; + expect(c.title).toBe("Index title"); + expect((await f.service.preview(c.id)).title).toBe("Index title"); + } finally { + await f.service.close(); + } + }); + it("records selected destination cwd for imported, skipped and failed outcomes", async () => { + const f = await fixture(); + const destination = join(f.root, "destination"); + await mkdir(destination); + try { + const c = (await f.service.scan(f)).candidates[0]; + const p = await f.service.preview(c.id); + const selection = { candidateId: c.id, fingerprint: p.fingerprint, cwd: destination }; + for (const status of ["imported", "skipped"] as const) { + const job = await finish(f.service, (await f.service.start([selection])).id); + expect(job.results[0]).toMatchObject({ status, cwd: destination }); + } + const missing = join(f.root, "missing"); + const failed = await finish(f.service, (await f.service.start([{ ...selection, cwd: missing }])).id); + expect(failed.results[0]).toMatchObject({ status: "failed", cwd: missing }); + await f.service.close(); + const restarted = new SessionImportService({ agentDir: f.agentDir }); + try { + expect((await restarted.getJob(failed.id)).results[0].cwd).toBe(missing); + } finally { + await restarted.close(); + } + } finally { + await f.service.close(); + } + }); + + it("recovers older result cwd, title and provenance only from a contained bounded native header", async () => { + const f = await fixture(); + const root = join(f.agentDir, "sessions", "imported"); + await mkdir(root, { recursive: true }); + const native = join(root, "native.jsonl"); + const outside = join(f.root, "outside.jsonl"); + const header = jsonl([ + { type: "session", cwd: f.cwd }, + { + type: "message", + message: { role: "user", content: "First native task" }, + imported: { source: "codex", sessionId: "s", itemId: "u" }, + }, + { type: "session_info", name: "[codex] Saved name" }, + ]); + await writeFile(native, header); + await writeFile(outside, header); + const linkPath = join(root, "escape.jsonl"); + await symlink(outside, linkPath); + const bounded = join(root, "bounded.jsonl"); + await writeFile( + bounded, + jsonl([ + { type: "session", cwd: f.cwd }, + { type: "ignored", padding: "x".repeat(1024 * 1024) }, + { type: "session_info", name: "Not read" }, + ]), + ); + const id = "11111111-1111-4111-a111-111111111111"; + const journal = join(f.agentDir, "session-imports", `${id}.json`); + await mkdir(join(f.agentDir, "session-imports")); + const job = { + id, + createdAt: timestamp, + status: "completed", + total: 6, + results: [ + { candidateId: "old", status: "imported", path: native }, + { + candidateId: "source-known", + status: "skipped", + path: native, + source: "claude-code", + title: "Existing title", + }, + { candidateId: "outside", status: "imported", path: outside }, + { candidateId: "symlink", status: "imported", path: linkPath }, + { candidateId: "bounded", status: "imported", path: bounded }, + { candidateId: "deleted", status: "imported", path: join(root, "missing.jsonl") }, + ], + }; + await writeFile(journal, JSON.stringify(job)); + try { + const results = (await f.service.getJob(id)).results; + expect(results[0]).toMatchObject({ cwd: f.cwd, title: "Saved name", source: "codex" }); + expect(results[1]).toMatchObject({ cwd: f.cwd, title: "Existing title", source: "claude-code" }); + for (const index of [2, 3, 5]) { + expect(results[index].cwd).toBeUndefined(); + expect(results[index].title).toBeUndefined(); + expect(results[index].source).toBeUndefined(); + } + expect(results[4]).toMatchObject({ cwd: f.cwd }); + expect(results[4].title).toBeUndefined(); + expect(results[4].source).toBeUndefined(); + await writeFile(native, header.split("\n").slice(0, 2).join("\n")); + expect((await f.service.getJob(id)).results[0].title).toBe("First native task"); + for (const cwd of [42, "relative", "", null, "/bad\0path"]) { + await writeFile(journal, JSON.stringify({ ...job, results: [{ ...job.results[0], cwd }] })); + await expect(f.service.getJob(id)).rejects.toThrow("Invalid persisted import result"); + } + } finally { + await f.service.close(); + } + }); + + it.each([5000, 1024 * 1024])( + "discovers and imports Claude metadata in a large first record with %s extra bytes", + async (extra) => { + const f = await fixture(); + const first = { + type: "user", + uuid: "long-user", + sessionId: "long-claude", + cwd: f.cwd, + timestamp, + message: { content: "" }, + }; + const emptyRecord = JSON.stringify(first); + const contentStart = emptyRecord.indexOf('"content":""') + '"content":"'.length; + const prefixBytes = Buffer.byteLength(emptyRecord.slice(0, contentStart)); + first.message.content = `${"x".repeat(65535 - prefixBytes)}\u754c${"x".repeat(extra)}`; + const input = jsonl([first]); + // Include a record without a trailing newline; split UTF-8 must not be decoded per chunk. + await writeFile(join(f.claudeRoot, "long.jsonl"), input.trimEnd()); + try { + const scan = await f.service.scan(f); + expect(scan.errors).toEqual([]); + const c = scan.candidates.find((candidate) => candidate.sessionId === "long-claude"); + expect(c).toBeDefined(); + const p = await f.service.preview(c?.id ?? ""); + const job = await finish( + f.service, + (await f.service.start([{ candidateId: p.id, fingerprint: p.fingerprint, cwd: f.cwd }])).id, + ); + expect(job.results[0].status).toBe("imported"); + const messages = SessionManager.open(job.results[0].path as string).buildSessionContext().messages; + expect(JSON.stringify(messages)).toContain("\u754c"); + expect(JSON.stringify(messages)).not.toContain("\uFFFD"); + } finally { + await f.service.close(); + } + }, + ); + + it("scans a selected date subtree but excludes archive subtrees even when selected directly", async () => { + const f = await fixture(); + const dateRoot = join(f.codexRoot, "sessions", "2026"); + const month = join(dateRoot, "10"); + const archivedRoot = join(f.codexRoot, "archived_sessions", "2026"); + await mkdir(month, { recursive: true }); + await mkdir(archivedRoot, { recursive: true }); + await writeFile(join(month, "rollout-day.jsonl"), jsonl([meta(f.cwd), user("dated")])); + await writeFile(join(archivedRoot, "rollout-archive.jsonl"), "{ malformed archive"); + try { + expect((await f.service.scan({ ...f, codexRoot: dateRoot })).candidates).toHaveLength(1); + const scan = await f.service.scan({ ...f, codexRoot: archivedRoot }); + expect(scan).toEqual({ + roots: { codex: await realpath(archivedRoot), "claude-code": await realpath(f.claudeRoot) }, + 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(); + } + }); +}); From 143b0094cf1edc548e677ac1c2d9638eda7c76d8 Mon Sep 17 00:00:00 2001 From: ZouR-Ma <2605315944@qq.com> Date: Tue, 27 Oct 2026 16:22:29 +0800 Subject: [PATCH 2/2] fix(web): centre the tag picker's rename pencil on the group name --- apps/web/client/src/features/sidebar/overlays.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/client/src/features/sidebar/overlays.css b/apps/web/client/src/features/sidebar/overlays.css index 7fe45642..797dd056 100644 --- a/apps/web/client/src/features/sidebar/overlays.css +++ b/apps/web/client/src/features/sidebar/overlays.css @@ -254,6 +254,8 @@ position: absolute; top: 50%; right: 5px; + /* Centres the pencil on the row's axis, level with the group name. */ + transform: translateY(-50%); display: none; place-items: center; width: 20px;