diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 8655c955..13517bd0 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -189,6 +189,11 @@ When to add or change one: - Every route is under `/api/v1/` (plus `/auth/*`, `/healthz`). It must pass the auth cookie and Origin checks in `server.ts`; the server listens on `127.0.0.1` only. Never log the launch token or the cookie secret. +- `/api/v1/session-imports` adapts the coding-agent offline migration service. + Scan the Host machine, exclude archives by default, and require previewed + fingerprints plus explicit destination directories before importing. Close + the importer before the session index. Imported tools never create native + child-agent links, revertable file changes or running-state indicators. - Adding or changing a route or payload: update `openapi.yaml` in the same change, update `client/src/state/types.ts`, and add a unit test. - Session snapshots and every SSE event carry `revision`, monotonic per bound session. `renderSnapshot` ignores a @@ -209,6 +214,9 @@ When to add or change one: separate. Preserve original timeline indices for turn footers, file deliveries and search reveal. Group completion counts tool results, never canonical Tasks or child-agent success. + Imported ordinary calls with unknown outcomes may join folded groups; a group + containing unconfirmed results uses a neutral recorded-call summary, never a + completion claim. Historical tool calls cannot make a group running. - A subagent record is context the main agent produced, so its inspector has no prompt surface: the child's input is a collapsed "task message received" receipt, never a bubble or box, and the parent composer is hidden while the record is open (closing it restores the composer). Do not add a composer, diff --git a/apps/web/client/public/session-import-logos/claudecode.svg b/apps/web/client/public/session-import-logos/claudecode.svg new file mode 100644 index 00000000..281f8860 --- /dev/null +++ b/apps/web/client/public/session-import-logos/claudecode.svg @@ -0,0 +1,10 @@ + + Claude Code + + + + + + + + diff --git a/apps/web/client/public/session-import-logos/codex.svg b/apps/web/client/public/session-import-logos/codex.svg new file mode 100644 index 00000000..d72e272b --- /dev/null +++ b/apps/web/client/public/session-import-logos/codex.svg @@ -0,0 +1,4 @@ + + Codex + + diff --git a/apps/web/client/public/session-import-logos/qoder-dark.svg b/apps/web/client/public/session-import-logos/qoder-dark.svg new file mode 100644 index 00000000..aac56b17 --- /dev/null +++ b/apps/web/client/public/session-import-logos/qoder-dark.svg @@ -0,0 +1,4 @@ + + Qoder + + diff --git a/apps/web/client/public/session-import-logos/workbuddy.svg b/apps/web/client/public/session-import-logos/workbuddy.svg new file mode 100644 index 00000000..cd52c984 --- /dev/null +++ b/apps/web/client/public/session-import-logos/workbuddy.svg @@ -0,0 +1,4 @@ + + WorkBuddy + + diff --git a/apps/web/client/src/features/conversation/ConversationStream.tsx b/apps/web/client/src/features/conversation/ConversationStream.tsx index 6797dcf9..8392f0de 100644 --- a/apps/web/client/src/features/conversation/ConversationStream.tsx +++ b/apps/web/client/src/features/conversation/ConversationStream.tsx @@ -6,6 +6,7 @@ import { setUi, useAppStore } from "../../state/store"; import type { MessageTimelineItem, SteeringMessage, TimelineItem } from "../../state/types"; import { Icon } from "../../ui"; import { Composer } from "../composer"; +import { openSettings } from "../settings/actions"; import { ClarificationPanel } from "./ClarificationPanel"; import { CommandRow } from "./CommandRow"; import { CompactionRow, CompactionRunningRow } from "./CompactionRow"; @@ -35,12 +36,26 @@ import { collectTurnFooters } from "./turn-footers"; */ function EmptyConversation() { const t = useT(); + const draft = useAppStore((state) => state.current === null); return ( -
- -

{t("emptyTitle")}

+
+
+ +

{t("emptyTitle")}

+
+ {draft ? ( + + ) : null}
); } diff --git a/apps/web/client/src/features/conversation/ToolCall.tsx b/apps/web/client/src/features/conversation/ToolCall.tsx index 6ff36dea..9a0be293 100644 --- a/apps/web/client/src/features/conversation/ToolCall.tsx +++ b/apps/web/client/src/features/conversation/ToolCall.tsx @@ -82,9 +82,9 @@ export const ToolRow = memo(function ToolRow({ const call = callPath(item.args, cwd); const shown = call.shown; // Only a file opens in the preview; a search or list path is usually a folder. - const openable = FILE_KINDS.has(toolKind(item)) ? call.openable : null; + const openable = !item.imported && FILE_KINDS.has(toolKind(item)) ? call.openable : null; const copyValue = toolKind(item) === "bash" ? (item.args?.command ?? "") : output; - const card = toolKind(item) === "edit" || toolKind(item) === "write"; + const card = !item.imported && (toolKind(item) === "edit" || toolKind(item) === "write"); if (!readOnly && item.toolName === "subagent" && item.subagentTasks?.length && item.state !== "failed") { return ; } diff --git a/apps/web/client/src/features/conversation/process-groups.ts b/apps/web/client/src/features/conversation/process-groups.ts index cb37c436..392447c8 100644 --- a/apps/web/client/src/features/conversation/process-groups.ts +++ b/apps/web/client/src/features/conversation/process-groups.ts @@ -52,7 +52,9 @@ export function groupProcessRows( rows: pending, running: pending.some( ({ item, index }) => - active && index > lastUser && (item.kind === "thinking" ? item.running : item.state === "pending"), + active && + index > lastUser && + (item.kind === "thinking" ? item.running : !item.imported && item.state === "pending"), ), }); pending = []; @@ -69,7 +71,9 @@ export function groupProcessRows( !item.children?.length && !item.workflowRun && item.state !== "failed" && - (item.state === "completed" || (active && index > lastUser && !awaiting.has(item.toolName)))) + (item.state === "completed" || + (item.imported && item.state === "unknown") || + (active && index > lastUser && !awaiting.has(item.toolName)))) ) { pending.push({ ...row, item }); } else { @@ -137,6 +141,9 @@ export function processGroupSummary( }; } if (!tools.length) return { label: t("processThinkingRecorded"), detail: "", icon: "brain" }; + if (tools.some((tool) => tool.state !== "completed")) { + return { label: t("processRecordedTools", { count: tools.length }), detail: "", icon: "wrench" }; + } const counts = new Map(); for (const tool of tools) { const kind = toolKind(tool); diff --git a/apps/web/client/src/features/conversation/stream.css b/apps/web/client/src/features/conversation/stream.css index 52cc0b89..cec84c8e 100644 --- a/apps/web/client/src/features/conversation/stream.css +++ b/apps/web/client/src/features/conversation/stream.css @@ -140,14 +140,22 @@ .main:has(.empty-state):not(:has(.stream[data-opening])) > .composer-zone { flex: 1 1 auto; } -/* The mark leads the question on one line, centred on the column, just above the composer. */ +.empty-conversation { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + flex: 1; + padding: 0 2px 16px; +} +/* The mark and question stay centred on one line, independently of the migration shortcut. */ .empty-state { display: flex; align-items: flex-end; justify-content: center; gap: 14px; - flex: 1; - padding: 0 2px 16px; + flex: 0 0 auto; + width: 100%; color: var(--ink-2); text-align: center; } @@ -197,6 +205,21 @@ } } +.empty-session-import { + display: flex; + align-items: center; + gap: 8px; + margin-top: 20px; + padding: 8px 12px; + border-radius: var(--radius-control); + color: var(--ink-2); + font-size: 13px; + text-align: left; + transition: background-color var(--fast), color var(--fast); +} +.empty-session-import:hover { background: var(--hover); color: var(--ink); } +.empty-session-import:focus-visible { outline: 2px solid var(--focus-border); outline-offset: 2px; } + .message { display: flex; flex-direction: column; diff --git a/apps/web/client/src/features/conversation/tool-presentation.ts b/apps/web/client/src/features/conversation/tool-presentation.ts index df852f7d..9cf3efd0 100644 --- a/apps/web/client/src/features/conversation/tool-presentation.ts +++ b/apps/web/client/src/features/conversation/tool-presentation.ts @@ -14,12 +14,44 @@ const KIND_BY_TOOL: Readonly> = { ls: "list", }; -export function toolKind(item: Pick): ToolKind { +/** Presentation-only grouping of historical tool names; execution names are never rewritten. */ +const IMPORTED_KIND_BY_TOOL: Readonly> = { + exec_command: "bash", + shell: "bash", + bash: "bash", + run_terminal_cmd: "bash", + read: "read", + read_file: "read", + cat: "read", + write: "write", + write_file: "write", + apply_patch: "write", + create_file: "write", + edit: "edit", + edit_file: "edit", + str_replace_editor: "edit", + search_replace: "edit", + grep: "search", + glob: "search", + search_files: "search", + codebase_search: "search", + web_search: "search", + ls: "list", + list_dir: "list", + list_directory: "list", +}; + +export function toolKind(item: Pick): ToolKind { + if (item.imported) { + const name = item.toolName.toLowerCase(); + return Object.hasOwn(IMPORTED_KIND_BY_TOOL, name) ? IMPORTED_KIND_BY_TOOL[name] : "other"; + } return Object.hasOwn(KIND_BY_TOOL, item.toolName) ? KIND_BY_TOOL[item.toolName] : "other"; } /** State comes from the host, never from output text or the whole turn's activity. */ -export function toolLabel(item: Pick, t: TranslateFn): string { +export function toolLabel(item: Pick, t: TranslateFn): string { + if (item.state === "unknown") return item.toolName || t("toolStatus.unknown"); const kind = toolKind(item); const action = item.toolName === "find" ? "find" : kind; return t(`toolAction.${action}.${item.state}`, { name: item.toolName || t("approvalTool") }); diff --git a/apps/web/client/src/features/conversation/turn-artifacts.ts b/apps/web/client/src/features/conversation/turn-artifacts.ts index a3fb99a2..9b960ad6 100644 --- a/apps/web/client/src/features/conversation/turn-artifacts.ts +++ b/apps/web/client/src/features/conversation/turn-artifacts.ts @@ -37,7 +37,7 @@ export function turnArtifacts( const keyOf = (path: string) => (windows ? path.toLowerCase() : path); for (let index = Math.max(0, start); index <= footer.lastIndex; index++) { const item = items[index]; - if (item.kind !== "tool" || item.state !== "completed") continue; + if (item.kind !== "tool" || item.state !== "completed" || item.imported) continue; if (!["edit", "write", "edit_file", "write_file", "apply_patch"].includes(item.toolName)) continue; const raw = item.change?.path ?? item.args?.path; const path = raw ? workspaceFilePath(cwd, raw) : undefined; diff --git a/apps/web/client/src/features/settings/SessionImportPage.tsx b/apps/web/client/src/features/settings/SessionImportPage.tsx new file mode 100644 index 00000000..a9ff408c --- /dev/null +++ b/apps/web/client/src/features/settings/SessionImportPage.tsx @@ -0,0 +1,500 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useT } from "../../i18n"; +import { api, apiSend } from "../../lib/api"; +import { openSession, refreshSessions } from "../../state/session"; +import { useAppStore } from "../../state/store"; +import type { ImportCandidate, ImportJob, ImportPreview } from "../../state/types"; +import { Button, Icon, Popover, SearchInput } from "../../ui"; +import { closeSettings } from "./actions"; +import { displayableImportCandidates, pendingImportCandidates } from "./session-import-history"; +import "./session-import.css"; + +const PATH = "/api/v1/session-imports"; +const SOURCES = [ + { id: "codex", name: "Codex", logo: "/session-import-logos/codex.svg" }, + { id: "claude-code", name: "Claude Code", logo: "/session-import-logos/claudecode.svg" }, + { id: "workbuddy", name: "WorkBuddy", logo: "/session-import-logos/workbuddy.svg" }, + { id: "qoder", name: "Qoder", logo: "/session-import-logos/qoder-dark.svg" }, +] as const; + +function groupProjects(rows: readonly T[], cwd: (row: T) => string) { + const groups = new Map(); + for (const row of rows) { + const path = cwd(row); + const items = groups.get(path) ?? []; + items.push(row); + groups.set(path, items); + } + return [...groups]; +} + +function projectName(path: string, fallback: string) { + return path.split(/[\\/]/u).filter(Boolean).at(-1) || fallback; +} + +function remainingSelected(selected: ReadonlySet, job: ImportJob) { + const completed = new Set( + job.results.filter((result) => result.status !== "failed").map((result) => result.candidateId), + ); + return new Set([...selected].filter((id) => !completed.has(id))); +} + +export function SessionImportPage() { + const t = useT(); + const sessions = useAppStore((state) => state.sessions); + const [candidates, setCandidates] = useState([]); + const [selected, setSelected] = useState>(new Set()); + const [previews, setPreviews] = useState>(new Map()); + const [targets, setTargets] = useState>(new Map()); + const [query, setQuery] = useState(""); + const [scanned, setScanned] = useState(false); + const [scanGeneration, setScanGeneration] = useState(0); + const [busy, setBusy] = useState(false); + const [focused, setFocused] = useState(); + const [job, setJob] = useState(); + const [jobs, setJobs] = useState([]); + const mounted = useRef(true); + const autoScanned = useRef(false); + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + useEffect(() => { + if (job?.status !== "running") return; + let stopped = false; + let timer: ReturnType | undefined; + const poll = async () => { + try { + const next = await api(`${PATH}/${job.id}`); + if (stopped) return; + setJob(next); + setJobs((current) => [next, ...current.filter((item) => item.id !== next.id)]); + if (next.status === "running") timer = setTimeout(() => void poll(), 500); + else { + setSelected((current) => remainingSelected(current, next)); + await refreshSessions(); + } + } catch { + // Read-only retries preserve the last known job state without displaying technical diagnostics. + if (!stopped) timer = setTimeout(() => void poll(), 2000); + } + }; + void poll(); + return () => { + stopped = true; + if (timer) clearTimeout(timer); + }; + }, [job?.id, job?.status]); + + const pending = busy || job?.status === "running"; + const available = useMemo( + () => displayableImportCandidates(candidates, jobs, previews), + [candidates, jobs, previews], + ); + const visible = useMemo( + () => displayableImportCandidates(candidates, jobs, previews, query), + [candidates, jobs, previews, query], + ); + const chosen = available.filter((c) => selected.has(c.id)); + + function groupCheckbox(rows: readonly ImportCandidate[], scope: string) { + const all = rows.length > 0 && rows.every((row) => selected.has(row.id)); + const some = rows.some((row) => selected.has(row.id)); + const label = scope.includes(":") + ? projectName(rows[0]?.cwd ?? "", t("importUnknownProject")) + : (SOURCES.find((source) => source.id === scope)?.name ?? t("importUnknownSource")); + return ( + { + if (input) input.indeterminate = some && !all; + }} + onClick={(event) => event.stopPropagation()} + onChange={(event) => { + const checked = event.currentTarget.checked; + setSelected((current) => { + const next = new Set(current); + for (const row of rows) { + if (checked) next.add(row.id); + else next.delete(row.id); + } + return next; + }); + }} + /> + ); + } + + const preview = useCallback(async (ids: readonly string[], show = true) => { + setBusy(true); + if (show && ids[0]) setFocused(ids[0]); + try { + for (const id of ids) { + if (!mounted.current) return; + try { + const value = await apiSend(`${PATH}/preview`, "POST", { candidateId: id }); + if (!mounted.current) return; + setPreviews((current) => new Map(current).set(id, value)); + } catch { + // An unavailable preview must not leave a stale selectable row or open disclosure. + if (!mounted.current) return; + setPreviews((current) => { + const next = new Map(current); + next.delete(id); + return next; + }); + setSelected((current) => { + const next = new Set(current); + next.delete(id); + return next; + }); + setFocused((current) => (current === id ? undefined : current)); + } + } + } finally { + if (mounted.current) setBusy(false); + } + }, []); + + const scan = useCallback(async () => { + setBusy(true); + setScanned(false); + setPreviews(new Map()); + setSelected(new Set()); + setFocused(undefined); + try { + const history = await api<{ jobs: ImportJob[] }>(PATH); + if (!mounted.current) return; + setJobs(history.jobs); + setJob( + (current) => + history.jobs.find((item) => item.status === "running") ?? + (current && history.jobs.find((item) => item.id === current.id)), + ); + const result = await apiSend<{ candidates: ImportCandidate[] }>(`${PATH}/scan`, "POST", { + includeArchived: false, + }); + if (!mounted.current) return; + setCandidates(result.candidates); + setSelected(new Set()); + setPreviews(new Map()); + setTargets(new Map(result.candidates.map((candidate) => [candidate.id, candidate.cwd]))); + setFocused(undefined); + setScanned(true); + setScanGeneration((current) => current + 1); + await preview( + pendingImportCandidates(result.candidates, history.jobs).map((candidate) => candidate.id), + false, + ); + } catch { + // Preserve known history and metadata; failed optional discovery renders no source list. + if (mounted.current) { + setScanned(false); + setPreviews(new Map()); + } + } finally { + if (mounted.current) setBusy(false); + } + }, [preview]); + useEffect(() => { + if (autoScanned.current) return; + autoScanned.current = true; + void scan(); + }, [scan]); + + async function start(source: ImportCandidate["source"]) { + const platformChosen = chosen.filter((candidate) => candidate.source === source); + if ( + !platformChosen.length || + platformChosen.length > 100 || + !platformChosen.every((c) => previews.has(c.id) && targets.get(c.id)?.trim()) + ) + return; + setBusy(true); + try { + const next = await apiSend(PATH, "POST", { + selections: platformChosen.map((c) => ({ + candidateId: c.id, + fingerprint: previews.get(c.id)?.fingerprint, + cwd: targets.get(c.id), + })), + }); + if (mounted.current) { + setJob(next); + if (next.status !== "running") setSelected((current) => remainingSelected(current, next)); + setJobs((current) => [next, ...current.filter((item) => item.id !== next.id)]); + } + } catch { + // Submission is unconfirmed; keep selection and history, never auto-retry or invent success. + } finally { + if (mounted.current) setBusy(false); + } + } + + async function cancel() { + if (!job) return; + try { + await apiSend(`${PATH}/${job.id}`, "DELETE"); + } catch { + // Keep the last known running state; only a confirmed host response can report cancellation. + } + } + + return ( +
+
+
+

{t("importSourcesTitle")}

+
+ +
+
+
+ setQuery(event.currentTarget.value)} + /> +
+
+ {SOURCES.map((source) => { + const rows = visible.filter((candidate) => candidate.source === source.id); + const platformChosen = chosen.filter((candidate) => candidate.source === source.id); + const ready = + platformChosen.length > 0 && + platformChosen.every((c) => previews.has(c.id) && targets.get(c.id)?.trim()); + if (!scanned || !rows.length) return null; + return ( +
+ + {groupCheckbox(rows, source.id)} + {"logo" in source ? ( + + ) : ( + +
+ {groupProjects(rows, (candidate) => candidate.cwd).map(([cwd, projectRows]) => ( +
+ + {groupCheckbox(projectRows, `${source.id}:${cwd}`)} + + + {projectName(cwd, t("importUnknownProject"))} + + + {t("importSelected", { + count: available.filter( + (candidate) => + candidate.source === source.id && + candidate.cwd === cwd && + selected.has(candidate.id), + ).length, + })} + + +
+ {projectRows.map((candidate) => { + const p = previews.get(candidate.id); + return ( +
+ + setFocused(undefined)} + content={ + p ? ( + <> + {p.title} +
{p.excerpt}
+ + ) : null + } + > + +
+ + {p ? ( +
+ + {t("importCounts", { + messages: p.messageCount, + tools: p.toolCount, + })} + +
+ ) : 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 (!rows.length) return null; + return ( +
+ + {"logo" in source ? ( + + ) : ( + +
+ {groupProjects( + rows, + ({ result }) => + sessions.find((session) => session.id === result.sessionId)?.cwd || + candidates.find((candidate) => candidate.id === result.candidateId)?.cwd || + "", + ).map(([cwd, projectRows]) => ( +
+ + + + {projectName(cwd, t("importUnknownProject"))} + +
+ {projectRows.map(({ result, index, job: item }) => ( +
+
+ {result.title ?? t("importUnknownProject")} +

{t(`importResult.${result.status}`)}

+
+ {result.sessionId ? ( + + ) : null} +
+ ))} +
+
+ ))} +
+
+ ); + })} +
+
+ ); +} diff --git a/apps/web/client/src/features/settings/SettingsView.tsx b/apps/web/client/src/features/settings/SettingsView.tsx index 497e92fd..e0785d98 100644 --- a/apps/web/client/src/features/settings/SettingsView.tsx +++ b/apps/web/client/src/features/settings/SettingsView.tsx @@ -16,6 +16,7 @@ import { GeneralPage } from "./GeneralPage"; import { McpPage } from "./McpPage"; import { ModelsPage } from "./ModelsPage"; import { PluginsPage } from "./PluginsPage"; +import { SessionImportPage } from "./SessionImportPage"; import { SkillsPage } from "./SkillsPage"; interface MenuEntry { @@ -32,6 +33,7 @@ const MENU: ReadonlyArray<{ readonly title: string; readonly entries: readonly M { page: "general", label: "settingsGeneral", icon: "sliders" }, { page: "models", label: "settingsModels", icon: "cube" }, { page: "appearance", label: "settingsAppearance", icon: "palette" }, + { page: "migration", label: "importTitle", icon: "folder" }, ], }, { @@ -60,6 +62,8 @@ function pageContent(page: SettingsPage): ReactNode { return ; case "skills": return ; + case "migration": + return ; } } diff --git a/apps/web/client/src/features/settings/session-import-history.ts b/apps/web/client/src/features/settings/session-import-history.ts new file mode 100644 index 00000000..a02c2807 --- /dev/null +++ b/apps/web/client/src/features/settings/session-import-history.ts @@ -0,0 +1,41 @@ +import type { ImportCandidate, ImportJob, ImportPreview } from "../../state/types"; + +/** Scan IDs change; persisted source paths keep migrated sessions in history across scans. */ +export function pendingImportCandidates( + candidates: readonly ImportCandidate[], + jobs: readonly ImportJob[], +): ImportCandidate[] { + const completedIds = new Set(); + const completedPaths = new Set(); + for (const job of jobs) { + for (const result of job.results) { + if (result.status !== "imported" && result.status !== "skipped") continue; + if (result.candidateId) completedIds.add(result.candidateId); + if (result.source && result.sourcePath) completedPaths.add(`${result.source}\0${result.sourcePath}`); + } + } + return candidates.filter( + (candidate) => + !completedIds.has(candidate.id) && + !(candidate.sourcePath && completedPaths.has(`${candidate.source}\0${candidate.sourcePath}`)), + ); +} + +/** Discovery is best-effort: unreviewed, unavailable and unmatched candidates have no row. */ +export function displayableImportCandidates( + candidates: readonly ImportCandidate[], + jobs: readonly ImportJob[], + previews: ReadonlyMap, + query = "", +): ImportCandidate[] { + const needle = query.trim().toLowerCase(); + return pendingImportCandidates(candidates, jobs).filter((candidate) => { + const reviewed = previews.get(candidate.id); + return ( + !!reviewed && + `${reviewed.title || candidate.title} ${candidate.cwd} ${candidate.source} ${candidate.sourcePath ?? ""}` + .toLowerCase() + .includes(needle) + ); + }); +} diff --git a/apps/web/client/src/features/settings/session-import.css b/apps/web/client/src/features/settings/session-import.css new file mode 100644 index 00000000..7718340c --- /dev/null +++ b/apps/web/client/src/features/settings/session-import.css @@ -0,0 +1,282 @@ +.session-import-page { display: grid; grid-template-columns: minmax(0, 1fr); gap: 24px; } +.session-import-root { min-width: 0; flex: 1; overflow-wrap: anywhere; color: var(--ink-3); font-size: 11px; font-weight: 400; } +.session-import-toolbar > .session-import-check { font-size: 12px; color: var(--ink-2); } +.session-import-page > section { min-width: 0; } +.session-import-project-summary { min-width: 0; } +.session-import-section-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 8px; } +.session-import-section-head h1, .session-import-section-head h2 { font-size: 18px; font-weight: 600; } +.session-import-group { border: 1px solid var(--line); border-radius: var(--radius-surface); margin-block: 14px; background: var(--canvas); } +.session-import-app-summary { display: flex; align-items: center; gap: 14px; padding: 20px; cursor: pointer; list-style: none; font-size: 15px; } +.session-import-app-summary::-webkit-details-marker { display: none; } +.session-import-logo-placeholder { flex: 0 0 32px; width: 32px; height: 32px; } +.session-import-logo { flex: 0 0 32px; display: block; width: 32px; height: 32px; object-fit: contain; border-radius: 8px; } +.session-import-app-info { flex: 1; min-width: 0; display: grid; gap: 5px; } +.session-import-app-info strong { font-weight: 600; } +.session-import-app-info .session-import-root { display: none; } +.session-import-app-summary > .session-import-app-count { flex: none; color: var(--ink-2); font-size: 12px; } +.session-import-app-chevron { flex: none; margin-left: 4px; color: var(--ink-2); transform: rotate(90deg); } +.session-import-group[open] > .session-import-app-summary .session-import-app-chevron { transform: rotate(270deg); } +.session-import-group[open] > .session-import-app-summary { border-bottom: 1px dashed var(--line); } +.session-import-app-body { padding: 8px 20px 16px 52px; } +.session-import-project { margin-top: 4px; } +.session-import-project + .session-import-project { border-top: 1px dashed var(--line); } +.session-import-project-summary { display: flex; align-items: center; gap: 8px; padding: 16px 0; cursor: pointer; list-style: none; font-size: 13px; } +.session-import-project-summary::-webkit-details-marker { display: none; } +.session-import-project-summary strong { font-weight: 550; overflow-wrap: anywhere; min-width: 0; } +.session-import-project-summary > span { margin-left: 0; flex: none; font-size: 11px; color: var(--ink-3); } +.session-import-project-summary > .icon:first-of-type { order: 1; flex: none; margin-left: 8px; transform: rotate(90deg); } +.session-import-project[open] > summary > .icon:first-of-type { transform: rotate(270deg); } +.session-import-project-summary:focus-visible { outline: 2px solid var(--focus-border); outline-offset: 2px; } +.session-import-project-path { color: var(--ink-3); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; flex: 1; font-weight: 400; } +.session-import-header-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; } +.session-import-selected { font-size: 12px; color: var(--ink-2); } +.session-import-select-group { flex: none; margin: 0; width: 14px; height: 14px; accent-color: var(--ink); cursor: pointer; } +.session-import-project-summary > span { flex: none; } +.session-import-project-summary strong { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.session-import-project-body { padding-left: 20px; } +.session-import-page .settings-page-desc { font-size: 12px; line-height: 1.6; } +.session-import-page .settings-btn { font-size: 12px; } +.session-import-app-body > .settings-field { padding: 10px; background: var(--surface-soft); border-radius: var(--radius-control); } +.session-import-app-body > .session-import-check { font-size: 12px; color: var(--ink-2); } +.session-import-history { border-top: 1px solid var(--line); padding-top: 20px; } +.session-import-source-picker { display: flex; gap: 8px; align-items: center; } +.session-import-source-picker input { flex: 1; min-width: 0; } +.session-import-source-picker button { flex: none; } +.session-import-path { font-size: 12px; color: var(--ink-2); overflow-wrap: anywhere; word-break: break-word; } +.session-import-group .settings-field { margin-bottom: 12px; } +.session-import-result > div { min-width: 0; } +.session-import-result p { font-size: 12px; color: var(--ink-2); margin-top: 4px; } +.session-import-app-summary:focus-visible { outline: 2px solid var(--focus-border); outline-offset: 2px; border-radius: var(--radius-control); } + +.session-import-sources { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.session-import-toolbar { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-block: 16px; +} + +.session-import-toolbar .search-input { + flex: 1; + min-width: 160px; + min-height: 40px; + padding: 9px 12px; + border: 1px solid var(--line); + border-radius: var(--radius-control); + background: var(--surface-soft); + transition: border-color var(--fast), box-shadow var(--fast), background-color var(--fast); +} +.session-import-toolbar .search-input:focus-within { + border-color: var(--focus-border); + background: var(--canvas); + box-shadow: 0 0 0 3px var(--accent-ring); +} +.session-import-toolbar .search-input input { font-size: 13px; color: var(--ink); } +.session-import-toolbar .search-input input:focus-visible { outline: none; } +.session-import-toolbar .search-input input::placeholder { color: var(--ink-3); } + +.session-import-check { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + cursor: pointer; +} + +.session-import-check input { + flex: 0 0 auto; + accent-color: var(--ink); +} + +.session-import-check span { + min-width: 0; + overflow-wrap: anywhere; +} + +.session-import-check strong { + font-size: 13px; + font-weight: 500; +} + +.session-import-check small { + display: block; + font-size: 12px; + color: var(--ink-2); + margin-top: 4px; +} + +.session-import-group h2, +.session-import-preview h2, +.session-import-job h2 { + font-size: 13px; + font-weight: 600; + overflow-wrap: anywhere; + margin-block: 16px 8px; +} + +.session-import-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + column-gap: 12px; + row-gap: 4px; + margin-block: 8px; + padding: 12px 10px; + border-radius: var(--radius-control); + transition: background-color var(--fast); +} +.session-import-row > .session-import-check { display: contents; } +.session-import-row > .session-import-check > input { grid-column: 1; grid-row: 1 / 3; align-self: center; margin: 0; } +.session-import-row > .session-import-check > span { grid-column: 2; grid-row: 1; } +.session-import-row > .session-import-check strong { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.session-import-row > button { grid-column: 3; grid-row: 1 / 3; align-self: center; } +.session-import-row > .session-import-report { grid-column: 2; grid-row: 2; padding-left: 0; } +.session-import-row:has(> .session-import-check input:disabled) .session-import-check { cursor: default; } +.session-import-row:has(> .session-import-check input:checked) { + background: var(--surface-soft); +} + +.session-import-target, +.session-import-report { + grid-column: 1 / -1; +} + +.session-import-target { + display: flex; + align-items: end; + gap: 8px; +} + +.session-import-target label { + flex: 1; + min-width: 0; +} + +.session-import-excerpt { + grid-column: 1 / -1; + font-size: 12px; + line-height: 1.6; + color: var(--ink-2); + white-space: pre-wrap; + overflow-wrap: anywhere; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} +.session-import-preview-popover.session-import-preview-popover { + z-index: 100; + position-area: left center; + position-try-fallbacks: flip-inline, flip-block; + margin: 8px; + width: min(380px, calc(100vw - 32px)); + padding: 16px; +} +.session-import-preview-popover strong { font-size: 13px; overflow-wrap: anywhere; } +.session-import-preview-popover pre { + max-height: min(360px, 65vh); + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + font: 12px / 1.6 var(--mono); + margin-top: 10px; + color: var(--ink-2); +} + +.session-import-report { + padding-left: 24px; + display: flex; + flex-wrap: wrap; + gap: 6px 16px; + font-size: 12px; + color: var(--ink-2); +} + +.session-import-preview pre { + max-height: 320px; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + font-size: 12px; + padding: 12px; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 6px; +} + +.session-import-result { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding-block: 8px; + overflow-wrap: anywhere; +} + +@media (max-width: 640px) { + .settings-view:has([data-page="migration"]) { + grid-template-columns: 56px minmax(0, 1fr); + } + + .settings-view:has([data-page="migration"]) .settings-nav { + padding-inline: 8px; + } + + .settings-view:has([data-page="migration"]) .settings-nav-item, + .settings-view:has([data-page="migration"]) .settings-back { + justify-content: center; + padding-inline: 0; + } + + .settings-view:has([data-page="migration"]) .settings-nav-item span, + .settings-view:has([data-page="migration"]) .settings-back span { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } + + .settings-view:has([data-page="migration"]) .settings-nav-title, + .settings-view:has([data-page="migration"]) .settings-nav-note { + display: none; + } + + .settings-content[data-page="migration"] { + padding: 24px 16px 48px; + } + + .session-import-project-summary { flex-wrap: wrap; } + .session-import-project-summary .session-import-project-path { flex-basis: calc(100% - 40px); order: 5; } + .session-import-project-summary strong { flex: 1; max-width: none; } + .session-import-project-summary > span { margin-left: 0; } + .session-import-header-actions { justify-content: flex-start; width: 100%; min-width: 0; } + .session-import-section-head > h1 { width: 100%; } + .session-import-header-actions .session-import-selected { width: 100%; } + .session-import-app-summary { display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; gap: 8px; padding: 14px; } + .session-import-logo-placeholder { display: none; } + .session-import-logo { width: 24px; height: 24px; grid-column: 2; grid-row: 1; justify-self: start; } + .session-import-app-info { grid-column: 2; grid-row: 1; padding-left: 32px; } + .session-import-app-summary > .session-import-app-count { grid-column: 2; grid-row: 2; } + .session-import-app-summary > .session-import-select-group { grid-column: 1; grid-row: 1 / 3; align-self: center; justify-self: center; } + .session-import-app-summary > .session-import-platform-confirm { grid-column: 2 / 4; grid-row: 3; justify-self: end; } + .session-import-app-chevron { grid-column: 3; grid-row: 1; justify-self: end; } + .session-import-app-body { padding: 8px 14px 12px; } + .session-import-source-picker { flex-wrap: wrap; } + .session-import-source-picker input { flex-basis: 100%; } + .session-import-section-head { flex-wrap: wrap; } + .session-import-sources { grid-template-columns: minmax(0, 1fr); } + .session-import-toolbar { gap: 8px; } +} diff --git a/apps/web/client/src/features/sidebar/overlays.css b/apps/web/client/src/features/sidebar/overlays.css index 7fe45642..797dd056 100644 --- a/apps/web/client/src/features/sidebar/overlays.css +++ b/apps/web/client/src/features/sidebar/overlays.css @@ -254,6 +254,8 @@ position: absolute; top: 50%; right: 5px; + /* Centres the pencil on the row's axis, level with the group name. */ + transform: translateY(-50%); display: none; place-items: center; width: 20px; diff --git a/apps/web/client/src/i18n/messages.ts b/apps/web/client/src/i18n/messages.ts index 8a0ec8a1..95b2cc5b 100644 --- a/apps/web/client/src/i18n/messages.ts +++ b/apps/web/client/src/i18n/messages.ts @@ -169,6 +169,7 @@ const zhCN = { themeDark: "深色", emptyConversation: "还没有消息 — 在下方输入开始对话", emptyTitle: "今天想推进什么?", + emptySessionImport: "从其他AI应用导入会话", messagePlaceholder: "输入消息…", markdownPreview: "预览 Markdown", markdownEdit: "编辑 Markdown", @@ -379,6 +380,7 @@ const zhCN = { processThinkingElapsed: "思考 {duration}", processCompleted: "已完成:{summary}", processCompletedTools: "已完成 {count} 项工具操作", + processRecordedTools: "工具调用 {count} 项", processJoiner: "、", "processCount.read": "读取文件 {count} 项", "processCount.write": "写入文件 {count} 项", @@ -481,6 +483,7 @@ const zhCN = { "toolAction.other.completed": "调用了 {name}", "toolAction.other.failed": "调用 {name} 失败", "toolStatus.pending": "等待结果", + "toolStatus.unknown": "历史状态未记录", "toolStatus.completed": "完成", "toolStatus.failed": "失败", toolMoreLines: "… 其余 {count} 行", @@ -711,6 +714,60 @@ const zhCN = { settingsBuild: "当前版本", settingsBuildMadeAt: "这个窗口正在运行的程序和它的构建时间。", settingsBuildDev: "从源码启动,没有安装包的构建时间。", + importTitle: "会话迁移", + importSelectGroup: "全选", + importDeselectGroup: "取消全选", + importRescan: "重新扫描", + importNoConversation: "无可导入的对话(仅含命令或控制记录)", + importSourcesTitle: "从其他 AI 应用导入", + importHistoryTitle: "导入历史", + importAppCount: "{count} 个会话", + importChooseSource: "选择目录", + importBeforeScan: "扫描后将在这里显示此应用的会话", + importNoMatches: "没有匹配的会话,试试其他关键词或清空搜索", + importHistoryEmpty: "暂无导入记录", + importUnknownSource: "来源未记录", + importSubtitle: "从本机 Claude Code / Codex 导入已有会话。源文件只读,不会修改原应用的会话。", + importCodexRoot: "Codex 数据目录", + importClaudeRoot: "Claude Code projects 目录", + importIncludeArchived: "包含已归档会话", + importArchived: "已归档", + importScan: "扫描会话", + importWorking: "处理中", + importSearch: "搜索项目、来源或会话", + importSelectAll: "全选当前列表", + importEmpty: "没有可导入的会话", + importUnknownProject: "项目未记录", + importPreview: "预览", + importTarget: "目标项目目录", + importChooseTarget: "选择目标项目目录", + importCounts: "{messages} 条消息 · {tools} 次工具调用", + importSelected: "已选 {count} 项", + importScanErrors: "{count} 项扫描问题", + importReviewSelected: "检查所选会话", + importConfirm: "导入所选会话", + importCancel: "停止后续导入", + importOpen: "打开会话", + "importJob.running": "正在导入", + "importJob.completed": "导入结束", + "importJob.cancelled": "已停止", + "importJob.interrupted": "导入中断", + "importResult.imported": "已导入", + "importResult.skipped": "相同快照已存在", + "importResult.failed": "未导入", + "importWarning.attachment_missing": "附件缺失或不支持", + "importWarning.unsupported_content": "不支持的内容块", + "importWarning.tool_arguments_as_text": "工具参数按原文保留", + "importWarning.source_instructions_excluded": "来源控制指令未激活", + "importWarning.rolled_back_history": "已排除回滚历史", + "importWarning.encrypted_reasoning_excluded": "加密推理不可迁移", + "importWarning.unsupported_item": "特殊记录按历史原文保留", + "importWarning.source_compaction": "已保留来源压缩摘要", + "importWarning.claude_compaction": "仅迁移当前消息链,压缩前历史可能缺失", + "importWarning.child_history_excluded": "未合并子会话", + "importWarning.branch_parent_missing": "分支祖先记录缺失", + "importWarning.tool_result_missing": "工具结果未记录", + "importWarning.tool_call_missing": "工具调用未记录", settingsLanguage: "界面语言", settingsLanguageDesc: "切换后立即生效,并在下次打开时保持。", settingsAccount: "账号", @@ -1117,6 +1174,7 @@ const enUS: Record = { themeDark: "Dark", emptyConversation: "No messages yet — start a conversation below", emptyTitle: "What should we move forward today?", + emptySessionImport: "Import sessions from other AI apps", messagePlaceholder: "Type a message…", markdownPreview: "Preview Markdown", markdownEdit: "Edit Markdown", @@ -1332,6 +1390,7 @@ const enUS: Record = { processThinkingElapsed: "Thought for {duration}", processCompleted: "Completed: {summary}", processCompletedTools: "{count} tool operations completed", + processRecordedTools: "Recorded tool calls: {count}", processJoiner: ", ", "processCount.read": "{count} file reads", "processCount.write": "{count} file writes", @@ -1434,6 +1493,7 @@ const enUS: Record = { "toolAction.other.completed": "Called {name}", "toolAction.other.failed": "{name} failed", "toolStatus.pending": "Waiting for result", + "toolStatus.unknown": "Historical status not recorded", "toolStatus.completed": "Done", "toolStatus.failed": "Failed", toolMoreLines: "… {count} more lines", @@ -1673,6 +1733,60 @@ const enUS: Record = { settingsBuild: "Current build", settingsBuildMadeAt: "The program this window is running, and when it was built.", settingsBuildDev: "Running from source; an installed build would show its build time.", + importTitle: "Session Migration", + importSelectGroup: "Select all", + importDeselectGroup: "Deselect all", + importRescan: "Rescan", + importNoConversation: "No importable conversation (command/control records only)", + importSourcesTitle: "Import from other AI apps", + importHistoryTitle: "Import history", + importAppCount: "{count} sessions", + importChooseSource: "Choose directory", + importBeforeScan: "Scan to list this app's sessions", + importNoMatches: "No matching sessions. Try another keyword or clear the search.", + importHistoryEmpty: "No import records yet", + importUnknownSource: "Source not recorded", + importSubtitle: "Import local Claude Code / Codex sessions. Source files are read-only and remain unchanged.", + importCodexRoot: "Codex data directory", + importClaudeRoot: "Claude Code projects directory", + importIncludeArchived: "Include archived sessions", + importArchived: "Archived", + importScan: "Scan sessions", + importWorking: "Working", + importSearch: "Search projects, sources or sessions", + importSelectAll: "Select visible sessions", + importEmpty: "No importable sessions", + importUnknownProject: "Project not recorded", + importPreview: "Preview", + importTarget: "Destination project directory", + importChooseTarget: "Choose destination project directory", + importCounts: "{messages} messages · {tools} tool calls", + importSelected: "{count} selected", + importScanErrors: "{count} scan issues", + importReviewSelected: "Review selected sessions", + importConfirm: "Import selected sessions", + importCancel: "Stop remaining imports", + importOpen: "Open session", + "importJob.running": "Importing", + "importJob.completed": "Import finished", + "importJob.cancelled": "Stopped", + "importJob.interrupted": "Import interrupted", + "importResult.imported": "Imported", + "importResult.skipped": "Identical snapshot already exists", + "importResult.failed": "Not imported", + "importWarning.attachment_missing": "Attachment missing or unsupported", + "importWarning.unsupported_content": "Unsupported content block", + "importWarning.tool_arguments_as_text": "Tool arguments preserved as text", + "importWarning.source_instructions_excluded": "Source controls not activated", + "importWarning.rolled_back_history": "Rolled-back history excluded", + "importWarning.encrypted_reasoning_excluded": "Encrypted reasoning not portable", + "importWarning.unsupported_item": "Special item preserved as historical text", + "importWarning.source_compaction": "Source compaction summary retained", + "importWarning.claude_compaction": "Only the current chain is imported; pre-compaction history may be missing", + "importWarning.child_history_excluded": "Child sessions not merged", + "importWarning.branch_parent_missing": "Branch ancestor missing", + "importWarning.tool_result_missing": "Tool result not recorded", + "importWarning.tool_call_missing": "Tool call not recorded", settingsLanguage: "Language", settingsLanguageDesc: "Applies right away and is remembered next time.", settingsAccount: "Account", diff --git a/apps/web/client/src/lib/imported-content.ts b/apps/web/client/src/lib/imported-content.ts new file mode 100644 index 00000000..1fa2dccd --- /dev/null +++ b/apps/web/client/src/lib/imported-content.ts @@ -0,0 +1,60 @@ +const CONTROL_TAGS = [ + "system-reminder", + "environment_context", + "permissions instructions", + "user_instructions", + "developer_instructions", + "turn_aborted", + "request_id", + "turn_id", + "model", + "command-name", + "command-message", + "command-args", + "local-command-caveat", + "local-command-stdout", + "local-command-stderr", +]; +const PREFIX = new RegExp(`^\\s*<(${CONTROL_TAGS.join("|")})(?:\\s[^>]*)?>`); + +function unwrapUserQuery(value: string): string { + const start = /^\s*]*)?>/.exec(value); + if (!start) return value; + const tokens = /<\/?user_query(?:\s[^>]*)?>/g; + tokens.lastIndex = start[0].length; + let depth = 1; + for (let token = tokens.exec(value); token; token = tokens.exec(value)) { + depth += token[0].startsWith("]*)?>`, "g"); + tokens.lastIndex = first[0].length; + let depth = 1; + let end = -1; + for (let token = tokens.exec(text); token; token = tokens.exec(text)) { + depth += token[0].startsWith(" { setState({ workspaces: data.workspaces }); } -/** Open a session and follow its stream (legacy `openSession`, 3122-3130). */ -export async function openSession(id: string): Promise { +/** Open a session and follow its stream; optional discovery links can suppress technical error toasts. */ +export async function openSession(id: string, options: { reportErrors?: boolean } = {}): Promise { choseSession = true; // The draft this page left behind is gone: it was never a session, so its // placeholder row must not survive under the workspace it named. @@ -1547,7 +1559,7 @@ export async function openSession(id: string): Promise { renderSnapshot(snapshot); connectStream(id); } catch (error) { - if (getState().ui.openingSession === id) showError(errorMessage(error)); + if (getState().ui.openingSession === id && options.reportErrors !== false) showError(errorMessage(error)); } finally { if (getState().ui.openingSession === id) setUi({ openingSession: null }); } diff --git a/apps/web/client/src/state/store.ts b/apps/web/client/src/state/store.ts index a8ceaed4..2e4a1804 100644 --- a/apps/web/client/src/state/store.ts +++ b/apps/web/client/src/state/store.ts @@ -148,7 +148,7 @@ export interface PreviewState { } /** Pages of the settings view, in menu order. */ -export type SettingsPage = "general" | "account" | "appearance" | "models" | "plugins" | "mcp" | "skills"; +export type SettingsPage = "general" | "account" | "appearance" | "models" | "plugins" | "mcp" | "skills" | "migration"; /** * What a settings editor edits. `mcp` with a name edits that server, without one diff --git a/apps/web/client/src/state/types.ts b/apps/web/client/src/state/types.ts index 5faec065..d7a97791 100644 --- a/apps/web/client/src/state/types.ts +++ b/apps/web/client/src/state/types.ts @@ -151,6 +151,44 @@ export interface SessionEntry { readonly [key: string]: unknown; } +export interface ImportCandidate { + readonly id: string; + readonly source: "codex" | "claude-code" | "workbuddy" | "qoder"; + readonly sessionId: string; + readonly title: string; + readonly cwd: string; + readonly modified: string; + readonly archived: boolean; + readonly version: string; + readonly sourcePath?: string; +} + +export interface ImportPreview extends ImportCandidate { + readonly fingerprint: string; + readonly messageCount: number; + readonly toolCount: number; + readonly warnings: readonly { readonly code: string; readonly count: number }[]; + readonly excerpt: string; +} + +export interface ImportJob { + readonly id: string; + readonly createdAt: string; + readonly status: "running" | "completed" | "cancelled" | "interrupted"; + readonly total: number; + readonly results: readonly { + readonly candidateId: string; + readonly source?: "codex" | "claude-code" | "workbuddy" | "qoder"; + readonly sourcePath?: string; + readonly path?: string; + readonly title?: string; + readonly status: "imported" | "skipped" | "failed"; + readonly sessionId?: string; + readonly error?: string; + readonly warning?: string; + }[]; +} + /** A row of GET /api/v1/sessions. Snapshots are merged into these rows too. */ export interface SessionSummary { readonly id: string; @@ -410,7 +448,7 @@ export interface MessageTimelineItem { readonly promptCommand?: boolean; } -export type ToolState = "pending" | "completed" | "failed"; +export type ToolState = "pending" | "completed" | "failed" | "unknown"; export interface ThinkingTiming { readonly startedAt: number; @@ -430,6 +468,7 @@ export interface ThinkingTimelineItem { } export interface ToolTimelineItem { + readonly imported?: boolean; readonly itemId: string; readonly kind: "tool"; readonly state: ToolState; diff --git a/apps/web/openapi.yaml b/apps/web/openapi.yaml index ad71b34b..966c08f5 100644 --- a/apps/web/openapi.yaml +++ b/apps/web/openapi.yaml @@ -6,6 +6,122 @@ info: servers: - url: http://127.0.0.1:3077 paths: + /api/v1/session-imports/scan: + post: + description: Read-only discovery on the Host machine. Archived directories and child sessions are excluded by default. Symlinks are not traversed. + requestBody: + content: + application/json: + schema: + type: object + additionalProperties: false + description: Scans configured default source directories only. Custom path overrides are rejected. + properties: + includeArchived: { type: boolean, default: false } + responses: + "200": + description: Candidates with opaque IDs and per-source scan errors + content: + application/json: + schema: + type: object + required: [existingCount, roots, candidates, errors] + properties: + existingCount: { type: integer, minimum: 0, description: "Matching sessions already in the current candidate list." } + roots: + type: object + description: Actual resolved source directories, including empty scanned directories. + additionalProperties: { type: string } + candidates: { type: array, items: { $ref: "#/components/schemas/ImportCandidate" } } + errors: + type: array + items: + type: object + required: [source, message] + properties: + source: { type: string } + message: { type: string } + "400": { description: Invalid input } + /api/v1/session-imports/preview: + post: + description: Parse and retain an immutable read-only snapshot for 15 minutes (subject to bounded cache eviction and host restart). Does not create a session, start an agent or invoke a model. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [candidateId] + properties: + candidateId: { type: string } + responses: + "200": + description: Snapshot fingerprint, bounded excerpt and compatibility warnings + content: + application/json: + schema: { $ref: "#/components/schemas/ImportPreview" } + "400": { description: Unsupported or invalid source } + "409": { description: Candidate expired or source changed } + /api/v1/session-imports: + get: + description: List persisted import jobs. Previous-host unfinished jobs are marked interrupted. + responses: + "200": + description: Job history + content: + application/json: + schema: + type: object + properties: + jobs: { type: array, items: { $ref: "#/components/schemas/ImportJob" } } + post: + description: Import the exact retained preview snapshots, not later source changes, as new native sessions. Missing or expired previews require a new preview (409). Submitted snapshots are pinned until settlement. Identical source snapshots are skipped; newly previewed changed snapshots create new copies. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [selections] + properties: + selections: + type: array + minItems: 1 + maxItems: 100 + items: + type: object + required: [candidateId, fingerprint, cwd] + properties: + candidateId: { type: string } + fingerprint: { type: string, pattern: "^[a-f0-9]{64}$" } + cwd: { type: string, description: Existing absolute destination project directory } + responses: + "202": + description: Accepted job; poll the job resource for per-session results + content: + application/json: + schema: { $ref: "#/components/schemas/ImportJob" } + "400": { description: Invalid selection } + /api/v1/session-imports/{jobId}: + parameters: + - { name: jobId, in: path, required: true, schema: { type: string, format: uuid } } + get: + responses: + "200": + description: Current or persisted job + content: + application/json: + schema: { $ref: "#/components/schemas/ImportJob" } + "404": { description: Unknown job } + delete: + description: Request cancellation between sessions. Already published sessions remain; poll until the job settles. + responses: + "200": + description: Current job + content: + application/json: + schema: { $ref: "#/components/schemas/ImportJob" } + "404": { description: Unknown job } /api/v1/terminals: get: description: List Host-owned terminal metadata for a known workspace. Requires the Host cookie and allowed Origin; never starts a shell. @@ -2160,6 +2276,65 @@ paths: "200": { description: Workspace file contents } components: schemas: + ImportCandidate: + type: object + required: [id, source, sessionId, title, cwd, modified, archived, version] + properties: + id: { type: string } + source: { type: string, enum: [codex, claude-code, workbuddy, qoder] } + sessionId: { type: string } + title: + type: string + description: Readable bounded scan title; explicit source title, first user text, or project/source plus ID fallback. Preview may refine a fallback. + cwd: { type: string, description: Source project directory from discovered metadata } + sourcePath: { type: string, description: Absolute source JSONL path on the host } + modified: { type: string, format: date-time } + archived: { type: boolean } + version: { type: string } + ImportPreview: + allOf: + - { $ref: "#/components/schemas/ImportCandidate" } + - type: object + required: [fingerprint, messageCount, toolCount, warnings, excerpt] + properties: + fingerprint: { type: string } + messageCount: { type: integer } + toolCount: { type: integer } + excerpt: { type: string, maxLength: 12000 } + warnings: + type: array + items: + type: object + required: [code, count] + properties: + code: { type: string } + count: { type: integer } + ImportJob: + type: object + required: [id, createdAt, status, total, results] + properties: + id: { type: string, format: uuid } + createdAt: { type: string, format: date-time } + status: { type: string, enum: [running, completed, cancelled, interrupted] } + total: { type: integer } + results: + type: array + items: + type: object + required: [candidateId, status] + properties: + candidateId: { type: string } + source: { type: string, enum: [codex, claude-code, workbuddy, qoder], description: Source client; legacy recovery uses validated provenance only } + sourcePath: { type: string, description: Absolute source JSONL path on the host when known } + title: { type: string, description: Reviewed title or bounded native-header recovery for older results; absent when unknown } + cwd: + type: string + description: Selected absolute destination for imported, skipped or failed selections. Older results may recover native header cwd; absent when unknown or for batch-level journal failures. + status: { type: string, enum: [imported, skipped, failed] } + sessionId: { type: string } + path: { type: string } + error: { type: string } + warning: { type: string, description: "Session publication succeeded, but a secondary notification failed" } TerminalInfo: type: object required: [id, cwd, title, shell, status, cols, rows] diff --git a/apps/web/src/runtime.ts b/apps/web/src/runtime.ts index a02694b6..3a95ddd8 100644 --- a/apps/web/src/runtime.ts +++ b/apps/web/src/runtime.ts @@ -634,7 +634,10 @@ function goalRoundOf(entry: SessionEntry): number | undefined { export function projectEntry(entry: SessionEntry): Record { // Only assistant turns carry usage; other message kinds have none to report. - const usage = entry.type === "message" && entry.message.role === "assistant" ? entry.message.usage : undefined; + const usage = + entry.type === "message" && entry.message.role === "assistant" && !entry.imported + ? entry.message.usage + : undefined; const notification = entry.type === "custom_message" ? projectSubagentNotification(entry.customType, entry.details) : undefined; // The command echo's text rides the custom entry's data. @@ -644,6 +647,7 @@ export function projectEntry(entry: SessionEntry): Record { id: entry.id, type: entry.type, timestamp: entry.timestamp, + ...(entry.imported ? { imported: entry.imported } : {}), ...(entry.type === "message" ? { message: entry.message } : {}), ...(usage ? { usage } : {}), ...(entry.type === "custom_message" ? { content: entry.content, customType: entry.customType } : {}), diff --git a/apps/web/src/server.ts b/apps/web/src/server.ts index 59d75593..09e6adc6 100644 --- a/apps/web/src/server.ts +++ b/apps/web/src/server.ts @@ -17,6 +17,9 @@ import { resolveStepAgentDir, resolveStepCodeVersion, resolveStepConfigRoot, + resolveStepSessionDir, + SessionImportError, + SessionImportService, SessionSearchRequestError, } from "@step-harness/coding-agent"; import { AccountProfileCache } from "./account.ts"; @@ -295,6 +298,11 @@ configureStepMcpPool({ quitRelease: "idle" }); const mcpWarmup = createStepMcpHostWarmup(); const resyncMcp = (): void => void mcpWarmup.sync(defaultWorkspace()); const sessionSearch = new WebSessionSearch(resolveStepAgentDir()); +const sessionImports = new SessionImportService({ + agentDir: resolveStepAgentDir(), + sessionRoot: resolveStepSessionDir(), + onPersisted: (event) => sessionSearch.notifyPersisted(event), +}); const store = new WebSessionStore({ index: sessionSearch }); const workspaces = new WorkspaceRegistry(join(resolveStepAgentDir(), WORKSPACES_FILE)); const embeddedBrowser = new WebBrowser([port, launchPort]); @@ -874,6 +882,60 @@ const server = createServer(async (request, response) => { if (parts[2] === "workspace" && parts[3] === "git") { return await gitRoute(parts.slice(4).map(decodeURIComponent), method, url, request, response); } + if (parts[2] === "session-imports") { + try { + if (parts.length === 3 && method === "GET") + return json(response, 200, { jobs: await sessionImports.listJobs() }); + if (parts.length === 4 && parts[3] === "scan" && method === "POST") { + const input = await body(request); + for (const key of Object.keys(input)) + if (key !== "includeArchived") + return error(response, 400, "Only default-directory scanning is supported"); + if (input.includeArchived !== undefined && typeof input.includeArchived !== "boolean") + return error(response, 400, "includeArchived must be a boolean"); + return json( + response, + 200, + await sessionImports.scan({ + includeArchived: input.includeArchived === true, + }), + ); + } + if (parts.length === 4 && parts[3] === "preview" && method === "POST") { + const input = await body(request); + if (typeof input.candidateId !== "string") return error(response, 400, "candidateId is required"); + return json(response, 200, await sessionImports.preview(input.candidateId)); + } + if (parts.length === 3 && method === "POST") { + const input = await body(request); + if (!Array.isArray(input.selections)) return error(response, 400, "selections are required"); + const selections: { candidateId: string; fingerprint: string; cwd: string }[] = []; + for (const value of input.selections) { + if ( + !value || + typeof value !== "object" || + !("candidateId" in value) || + typeof value.candidateId !== "string" || + !("fingerprint" in value) || + typeof value.fingerprint !== "string" || + !("cwd" in value) || + typeof value.cwd !== "string" + ) + return error(response, 400, "Invalid import selection"); + selections.push({ candidateId: value.candidateId, fingerprint: value.fingerprint, cwd: value.cwd }); + } + return json(response, 202, await sessionImports.start(selections)); + } + if (parts.length === 4 && method === "GET") + return json(response, 200, await sessionImports.getJob(parts[3])); + if (parts.length === 4 && method === "DELETE") + return json(response, 200, await sessionImports.cancel(parts[3])); + return error(response, 404, "Not found"); + } catch (caught) { + if (caught instanceof SessionImportError) return error(response, caught.status, caught.message); + return error(response, 400, caught instanceof Error ? caught.message : String(caught)); + } + } if (parts[2] === "settings" && parts[3]) { return await settingsRoute(parts.slice(3).map(decodeURIComponent), method, url, request, response); } @@ -1275,6 +1337,7 @@ const shutdown = async (): Promise => { voice.stopAll(), (async () => { try { + await sessionImports.close(); await store.close(); } finally { // Flush sessions before stopping their index, even if session teardown fails. diff --git a/apps/web/src/turn-changes.ts b/apps/web/src/turn-changes.ts index 4542155d..e55fd1b0 100644 --- a/apps/web/src/turn-changes.ts +++ b/apps/web/src/turn-changes.ts @@ -261,7 +261,7 @@ export function turnChanges(entries: readonly SessionEntry[], turnId: string): T const calls = new Map(); for (const entry of entries.slice(start + 1)) { if (isUserMessage(entry)) break; - if (entry.type !== "message") continue; + if (entry.type !== "message" || entry.imported) continue; const message = entry.message as { role: string; content?: unknown; @@ -529,6 +529,7 @@ async function replayFile( const calls = new Map(); let content: string | undefined; for (const entry of entries.slice(0, end)) { + if (entry.imported) continue; if (entry.type === "custom_message" && entry.customType === TURN_REVERT_TYPE) { const turnId = (entry.details as { turnId?: unknown } | undefined)?.turnId; if (typeof turnId !== "string") continue; diff --git a/apps/web/test/client-empty-session-style.test.ts b/apps/web/test/client-empty-session-style.test.ts index 3873b9eb..d1565720 100644 --- a/apps/web/test/client-empty-session-style.test.ts +++ b/apps/web/test/client-empty-session-style.test.ts @@ -43,6 +43,22 @@ test("the stylesheet matches the markup the empty state renders", () => { assert.doesNotMatch(css, /\.empty-wordmark/u, "the retired wordmark styles"); }); +test("migration stays below the centred mark and question without an application subtitle", async () => { + assert.match(rule(".empty-conversation"), /flex-direction:\s*column/u); + assert.match(rule(".empty-conversation"), /align-items:\s*center/u); + assert.match(rule(".empty-state"), /flex:\s*0 0 auto/u); + assert.match( + component, + /className="empty-conversation">\s*
[\s\S]*?

[\s\S]*?<\/h1>\s*<\/div>\s*\{draft/u, + ); + assert.doesNotMatch(component, /emptySessionImportSources/u); + assert.doesNotMatch(css, /\.empty-session-import small/u); + const messages = await readFile(new URL("../client/src/i18n/messages.ts", import.meta.url), "utf8"); + assert.match(messages, /emptySessionImport: "从其他AI应用导入会话"/u); + assert.match(messages, /emptySessionImport: "Import sessions from other AI apps"/u); + assert.doesNotMatch(messages, /emptySessionImportSources/u); +}); + test("startup is not drawn as a centred new conversation", () => { // Before bootstrap picks a session the timeline is empty, but that is loading, // not a new conversation: painting it centred moved the composer into the middle diff --git a/apps/web/test/client-imported-content.test.ts b/apps/web/test/client-imported-content.test.ts new file mode 100644 index 00000000..2a60ce2c --- /dev/null +++ b/apps/web/test/client-imported-content.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { stripImportedUserControls } from "../client/src/lib/imported-content.ts"; +import { timelineFromEntries } from "../client/src/state/session.ts"; + +const wrapped = "你好"; + +test("imported user_query wrappers retain their actual text", () => { + assert.deepEqual(stripImportedUserControls(wrapped), { text: "你好", removed: false }); + assert.deepEqual( + stripImportedUserControls(`Source controls${wrapped}`), + { text: "你好", removed: true }, + ); +}); + +test("code examples and incomplete user_query markup are not stripped", () => { + for (const text of ["```xml\n你好\n```", "example: 你好", "unfinished"]) { + assert.equal(stripImportedUserControls(text).text, text); + } + assert.equal( + stripImportedUserControls("outer inner").text, + "outer inner", + ); +}); + +test("existing imported messages unwrap without changing ordinary native messages", () => { + for (const source of ["codex", "claude-code", "workbuddy", "qoder"]) { + const rows = timelineFromEntries([ + { id: "old", type: "message", imported: { source, sessionId: "s", itemId: "u" }, message: { role: "user", content: wrapped } }, + { id: "native", type: "message", message: { role: "user", content: wrapped } }, + ]); + assert.equal(rows[0].kind, "message"); + assert.equal(rows[0].kind === "message" && rows[0].content, "你好"); + assert.equal(rows[1].kind === "message" && rows[1].content, wrapped); + } +}); diff --git a/apps/web/test/client-process-groups.test.ts b/apps/web/test/client-process-groups.test.ts index 1b86a367..61daa599 100644 --- a/apps/web/test/client-process-groups.test.ts +++ b/apps/web/test/client-process-groups.test.ts @@ -141,6 +141,37 @@ test("unknown tool names are counted without using text to infer success or cate assert.equal(group.running, false); }); +test("imported unknown tool calls form a non-running group with a neutral summary", () => { + const items = [ + { ...tool("shell", "exec_command", "unknown"), imported: true }, + ...Array.from({ length: 10 }, (_, index) => ({ ...tool(`write-${index}`, "Write", "unknown"), imported: true })), + ]; + const before = structuredClone(items); + for (const active of [false, true]) { + const group = onlyGroup(items, active); + assert.equal(group.rows.length, 11); + assert.equal(group.running, false); + assert.deepEqual(group.rows.map((row) => row.index), Array.from({ length: 11 }, (_, index) => index)); + assert.deepEqual(processGroupSummary(group, zh), { label: "工具调用 11 项", detail: "", icon: "wrench" }); + assert.equal(processGroupSummary(group, en).label, "Recorded tool calls: 11"); + } + assert.deepEqual(items, before); + const mixed = onlyGroup([items[0], tool("completed")]); + assert.equal(processGroupSummary(mixed, zh).label, "工具调用 2 项"); +}); + +test("historical grouping does not hide native unknown calls or cross failed and user boundaries", () => { + const imported = { ...tool("imported", "Write", "unknown"), imported: true }; + const nativeUnknown = tool("native", "write", "unknown"); + assert.ok(!("kind" in groupProcessRows([nativeUnknown], false)[0])); + for (const boundary of [user, { ...tool("failed", "Write", "failed"), imported: true }]) { + const rows = groupProcessRows([imported, boundary, { ...imported, itemId: "next" }], false); + assert.equal(rows.length, 3); + assert.ok(!("kind" in rows[1])); + assert.equal(rows[1].item, boundary); + } +}); + test("regrouping a streaming turn keeps the objects of unchanged history rows and groups", () => { const history: TimelineItem[] = [user, thought("t1"), tool("r1"), prose, { ...user, itemId: "u2" }]; const before = groupProcessRows([...history, thought("t2", true)], true); diff --git a/apps/web/test/client-session-import-history.test.ts b/apps/web/test/client-session-import-history.test.ts new file mode 100644 index 00000000..69d22e0f --- /dev/null +++ b/apps/web/test/client-session-import-history.test.ts @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { displayableImportCandidates, pendingImportCandidates } from "../client/src/features/settings/session-import-history.ts"; +import type { ImportCandidate, ImportJob, ImportPreview } from "../client/src/state/types.ts"; + +function candidate(overrides: Partial = {}): ImportCandidate { + return { + id: "current-candidate", + source: "codex", + sessionId: "source-session", + title: "Source conversation", + cwd: "/project", + modified: "2026-10-27T00:00:00.000Z", + archived: false, + version: "test", + sourcePath: "/source/rollout.jsonl", + ...overrides, + }; +} + +function job( + results: ImportJob["results"], + status: ImportJob["status"] = "completed", +): ImportJob { + return { id: "job", createdAt: "2026-10-27T00:00:00.000Z", status, total: results.length, results }; +} + +const result = { + candidateId: "previous-scan-candidate", + source: "codex" as const, + sourcePath: "/source/rollout.jsonl", + status: "imported" as const, + sessionId: "target-session", +}; + +test("successful and already-existing imports leave only unimported candidates", () => { + const imported = candidate(); + const pending = candidate({ id: "pending", sourcePath: "/source/pending.jsonl" }); + for (const status of ["imported", "skipped"] as const) { + assert.deepEqual(pendingImportCandidates([imported, pending], [job([{ ...result, status }])]), [pending]); + } +}); + +test("persistent source identity hides imports after rescan, reopen, and source append", () => { + const rescanned = candidate({ id: "new-scan-id", modified: "2026-10-28T00:00:00.000Z" }); + const restoredJobs = JSON.parse(JSON.stringify([job([result])])) as ImportJob[]; + assert.deepEqual(pendingImportCandidates([rescanned], restoredJobs), []); +}); + +test("failed results and unprocessed cancelled selections remain retryable", () => { + const pending = candidate(); + assert.deepEqual(pendingImportCandidates([pending], [job([{ ...result, status: "failed" }])]), [pending]); + assert.deepEqual(pendingImportCandidates([pending], [job([], "cancelled")]), [pending]); +}); + +test("successful results leave the list before their running batch settles", () => { + for (const status of ["running", "cancelled", "interrupted"] as const) { + assert.deepEqual(pendingImportCandidates([candidate()], [job([result], status)]), []); + } +}); + +test("matching titles, other applications, and other source paths are not hidden", () => { + const otherSource = candidate({ id: "other-app", source: "qoder" }); + const otherPath = candidate({ id: "other-file", sourcePath: "/source/other.jsonl" }); + assert.deepEqual(pendingImportCandidates([otherSource, otherPath], [job([result])]), [otherSource, otherPath]); +}); + +test("legacy results without stable paths match only the current candidate ID", () => { + const current = candidate(); + assert.deepEqual(pendingImportCandidates([current], [job([{ candidateId: current.id, status: "imported" }])]), []); + assert.deepEqual(pendingImportCandidates([current], [job([{ candidateId: "old-id", status: "imported" }])]), [current]); +}); + +function preview(value: ImportCandidate): ImportPreview { + return { ...value, fingerprint: "fixture", messageCount: 1, toolCount: 0, warnings: [], excerpt: value.title }; +} + +test("discovery only displays successfully previewed candidates", () => { + const healthy = candidate(); + const failed = candidate({ id: "failed", sourcePath: "/source/bad.jsonl" }); + const previews = new Map([[healthy.id, preview(healthy)]]); + assert.deepEqual(displayableImportCandidates([healthy, failed], [], previews), [healthy]); + assert.deepEqual(displayableImportCandidates([healthy, failed], [], new Map()), []); +}); + +test("search without matches is empty and only matches reviewed candidate metadata", () => { + const healthy = candidate(); + const previews = new Map([[healthy.id, preview(healthy)]]); + assert.deepEqual(displayableImportCandidates([healthy], [], previews, "not-present"), []); + assert.deepEqual(displayableImportCandidates([healthy], [], previews, " ROLLOUT.JSONL "), [healthy]); + assert.deepEqual(displayableImportCandidates([healthy], [job([result])], previews), []); +}); + +test("removing a stale preview quietly removes the candidate", () => { + const healthy = candidate(); + const previews = new Map([[healthy.id, preview(healthy)]]); + previews.delete(healthy.id); + assert.deepEqual(displayableImportCandidates([healthy], [], previews), []); +}); + +test("a later failed retry does not restore an already imported source", () => { + const jobs = [job([{ ...result, status: "failed" }]), job([result])]; + assert.deepEqual(pendingImportCandidates([candidate()], jobs), []); +}); diff --git a/apps/web/test/client-session-open-errors.test.ts b/apps/web/test/client-session-open-errors.test.ts new file mode 100644 index 00000000..6f42adf7 --- /dev/null +++ b/apps/web/test/client-session-open-errors.test.ts @@ -0,0 +1,28 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { openSession } from "../client/src/state/session.ts"; +import { createInitialState, getState, setState } from "../client/src/state/store.ts"; + +for (const reportErrors of [false, true]) { + test(`session open ${reportErrors ? "reports errors by default" : "can quietly refuse an unavailable import"}`, async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + setState({ ...createInitialState(), current: { id: "existing", cwd: "/project", status: "idle" } }); + t.after(() => setState(createInitialState())); + t.mock.method(globalThis, "fetch", async () => Response.json({ error: "technical open diagnostic" }, { status: 500 })); + if (reportErrors) await openSession("unavailable"); + else await openSession("unavailable", { reportErrors: false }); + assert.equal(getState().current?.id, "existing"); + assert.equal(getState().ui.openingSession, null); + assert.equal(getState().toast?.message, reportErrors ? "technical open diagnostic" : undefined); + }); +} + +test("quiet migration session opening still reports expired authentication globally", async (t) => { + setState(createInitialState()); + t.after(() => setState(createInitialState())); + t.mock.method(globalThis, "fetch", async () => Response.json({ error: "unauthorized" }, { status: 401 })); + await openSession("unavailable", { reportErrors: false }); + assert.equal(getState().ui.authExpired, true); + assert.equal(getState().toast, null); + assert.equal(getState().ui.openingSession, null); +}); diff --git a/apps/web/test/e2e/process-groups.e2e.mjs b/apps/web/test/e2e/process-groups.e2e.mjs index 43318979..faf26dc9 100644 --- a/apps/web/test/e2e/process-groups.e2e.mjs +++ b/apps/web/test/e2e/process-groups.e2e.mjs @@ -1,10 +1,11 @@ import assert from "node:assert/strict"; import { existsSync } from "node:fs"; -import { mkdir } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { after, before, test } from "node:test"; import { fileURLToPath } from "node:url"; +import { SessionImportService, SessionManager } from "@step-harness/coding-agent"; import { chromium } from "playwright-core"; import { createServer } from "vite"; @@ -26,10 +27,12 @@ import "/src/features/conversation/stream.css"; import {IconSprite} from "/src/ui"; import {ConversationStream} from "/src/features/conversation/ConversationStream.tsx"; import {setState} from "/src/state/store.ts"; +import {timelineFromEntries} from "/src/state/session.ts"; window.fixture = (items, running = false) => setState({ - current:{id:"singleton-test",cwd:"/fixture",status:running?"running":"idle",entries:[]}, + booted:true,current:{id:"singleton-test",cwd:"/fixture",status:running?"running":"idle",entries:[]}, timelineItems:items }); +window.importedFixture = entries => window.fixture(timelineFromEntries(entries)); createRoot(document.getElementById("root")).render(<>); `; let vite; @@ -78,6 +81,62 @@ const command = { outputSummary: "fixture output", }; +async function importedToolEntries(source) { + const root = await mkdtemp(join(tmpdir(), "stepcode-imported-tool-groups-")); + const sourceDir = join(root, "source"); + await mkdir(sourceDir); + const timestamp = "2026-10-27T00:00:00.000Z"; + const sessionId = `groups-${source}`; + const records = source === "codex" ? [ + { type: "session_meta", timestamp, payload: { id: sessionId, cwd: root } }, + { type: "response_item", timestamp, payload: { type: "message", role: "user", content: [{ type: "input_text", text: "Historical tools" }] } }, + ] : [ + { type: "workspace-directories", sessionId }, + { type: "user", uuid: "user", parentUuid: null, sessionId, cwd: root, timestamp, message: { content: "Historical tools" } }, + ]; + let parentUuid = "user"; + for (let index = 0; index < 11; index++) { + const id = `call-${index}`; + const args = index + ? { [source === "qoder" ? "file_path" : "path"]: `水果${String(index).padStart(2, "0")}.md`, content: "Historical file content" } + : { [source === "codex" ? "cmd" : "command"]: "ls -la" }; + if (source === "codex") { + records.push( + { type: "response_item", timestamp, payload: { type: "function_call", call_id: id, name: index ? "write_file" : "exec_command", arguments: JSON.stringify(args) } }, + { type: "response_item", timestamp, payload: { type: "function_call_output", call_id: id, output: `Historical result ${index}` } }, + ); + } else { + records.push( + { type: "assistant", uuid: `assistant-${index}`, parentUuid, sessionId, timestamp, message: { content: [{ type: "tool_use", id, name: index ? "Write" : "Bash", input: args }] } }, + { type: "user", uuid: `result-${index}`, parentUuid: `assistant-${index}`, sessionId, timestamp, message: { content: [{ type: "tool_result", tool_use_id: id, content: `Historical result ${index}` }] } }, + ); + parentUuid = `result-${index}`; + } + } + if (source === "qoder") records.push({ type: "active-leaf", sessionId, leafUuid: parentUuid }); + await writeFile(join(sourceDir, `${source}.jsonl`), records.map((record) => JSON.stringify(record)).join("\n") + "\n"); + const service = new SessionImportService({ agentDir: join(root, "agent"), sessionRoot: join(root, "sessions") }); + try { + const scan = await service.scan({ customRoot: sourceDir }); + const candidate = scan.candidates.find((item) => item.source === source); + assert.ok(candidate, `Missing ${source} fixture`); + const preview = await service.preview(candidate.id); + let job = await service.start([{ candidateId: candidate.id, fingerprint: preview.fingerprint, cwd: root }]); + const deadline = Date.now() + 5000; + while (job.status === "running" && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 10)); + job = await service.getJob(job.id); + } + assert.equal(job.status, "completed"); + assert.equal(job.results[0].status, "imported"); + assert.equal(existsSync(join(root, "水果01.md")), false, "Import must not execute writes"); + return SessionManager.open(job.results[0].path).getEntries(); + } finally { + await service.close(); + await rm(root, { recursive: true, force: true }); + } +} + test("singleton thinking has one disclosure; streaming growth preserves its mounted body and manual choice", { skip }, async () => { const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); const errors = []; @@ -225,3 +284,46 @@ test("unopened singletons grow into folded groups; single-level layouts fit narr await page.close(); } }); + +for (const source of ["codex", "qoder"]) { + test(`${source} imported tools are folded with truthful states and expandable details`, { skip }, async () => { + const entries = await importedToolEntries(source); + const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + try { + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.goto(url); + await page.waitForFunction(() => typeof window.importedFixture === "function"); + await page.evaluate((items) => window.importedFixture(items), entries); + const group = page.locator(".process-tool-group"); + const header = page.locator(".tool-group-trigger"); + await header.waitFor({ state: "visible" }); + assert.equal(await group.count(), 1); + assert.equal(await group.getAttribute("data-running"), "false"); + assert.match(await header.innerText(), /工具调用 11 项|Recorded tool calls: 11/); + assert.doesNotMatch(await header.innerText(), /已完成|Completed/); + assert.equal(await header.getAttribute("aria-expanded"), "false"); + const body = page.locator(".tool-group-disclosure"); + assert.equal(await body.getAttribute("inert"), ""); + assert.ok(await body.evaluate((element) => element.getBoundingClientRect().height < 1)); + assert.equal(await page.locator('.tool-call[data-tool-state="unknown"]').count(), 11); + const screenshots = process.env.STEPCODE_WEB_QA_DIR || join(tmpdir(), "stepcode-imported-tool-group-qa"); + await mkdir(screenshots, { recursive: true }); + await page.screenshot({ path: join(screenshots, `${source}-folded.png`) }); + await header.focus(); + await page.keyboard.press("Enter"); + assert.equal(await header.getAttribute("aria-expanded"), "true"); + assert.equal(await body.getAttribute("inert"), null); + await page.locator(".tool-toggle").first().click(); + await page.getByText("Historical result 0", { exact: true }).waitFor(); + await page.screenshot({ path: join(screenshots, `${source}-expanded.png`) }); + await header.click(); + assert.equal(await header.getAttribute("aria-expanded"), "false"); + assert.ok(await body.evaluate((element) => element.getBoundingClientRect().height < 1)); + await header.click(); + assert.equal(await page.locator(".tool-toggle").first().getAttribute("aria-expanded"), "true"); + console.log(`${source} imported tool screenshots: ${screenshots}`); + } finally { + await page.close(); + } + }); +} diff --git a/apps/web/test/e2e/web.e2e.mjs b/apps/web/test/e2e/web.e2e.mjs index 21ffb847..1dd91696 100644 --- a/apps/web/test/e2e/web.e2e.mjs +++ b/apps/web/test/e2e/web.e2e.mjs @@ -241,6 +241,22 @@ const until = async (probe, label, timeout = 8000) => { await new Promise((resolve) => setTimeout(resolve, 25)); } }; +const migrationScanCompleted = async (action, label) => { + const originalFetch = window.fetch; + let completed = 0; + window.fetch = async (input, init) => { + const path = new URL(typeof input === 'string' ? input : input.url, location.href).pathname; + const response = await originalFetch(input, init); + if (path === '/api/v1/session-imports/scan' && (init?.method ?? 'GET') === 'POST') completed++; + return response; + }; + try { + await action(); + await until(() => completed > 0 && $('#importScan') && !$('#importScan').disabled, label); + } finally { + window.fetch = originalFetch; + } +}; const treeRows = () => $$("#fileTreeBody .file-row").map((row) => row.dataset.path); const treeRow = (path) => $$("#fileTreeBody .file-row").find((row) => row.dataset.path === path); const projects = () => $$("#projectGroups .project-group").map((group) => group.dataset.cwd); @@ -251,6 +267,480 @@ const tabs = () => $$("#previewTabs .preview-tab").map((tab) => tab.textContent. const ALT_META = 1 | 4 const SHIFT_META = 8 | 4 +test("session migration imports reviewed snapshots while sources continue writing", { skip }, async () => { + const codexRoot = join(base, "migration-codex") + const claudeRoot = join(base, "migration-claude", "projects") + await mkdir(join(codexRoot, "sessions"), { recursive: true }) + await mkdir(join(codexRoot, "archived_sessions"), { recursive: true }) + await mkdir(claudeRoot, { recursive: true }) + const timestamp = "2026-10-22T00:00:00.000Z" + const sourcePath = join(codexRoot, "sessions", "rollout-migration.jsonl") + const input = [ + { type: "session_meta", timestamp, payload: { id: "migration-codex", cwd: workspace, cli_version: "0.163.0-alpha.11", history_mode: "paginated" } }, + { type: "response_item", timestamp, payload: { type: "message", role: "user", content: [{ type: "input_text", text: "Migration acceptance quokkabridge" }] } }, + { type: "response_item", timestamp, payload: { type: "function_call", call_id: "migration-tool", name: "exec_command", arguments: '{"cmd":"never execute this historical call"}' } }, + ].map(JSON.stringify).join("\n") + "\n" + await writeFile(sourcePath, input) + // An unreadable archive must not even produce a scan/parse failure by default. + await writeFile(join(codexRoot, "archived_sessions", "rollout-archive.jsonl"), "{") + await writeFile(join(claudeRoot, "migration-claude.jsonl"), [ + { type: "user", uuid: "cc-user", parentUuid: null, sessionId: "migration-claude", cwd: workspace, timestamp, message: { content: "Claude migration acceptance" } }, + { type: "assistant", uuid: "cc-assistant", parentUuid: "cc-user", sessionId: "migration-claude", timestamp, message: { content: [{ type: "text", text: "Historical CC reply" }] } }, + ].map(JSON.stringify).join("\n") + "\n") + const scanned = await browser.run(` + if ($("#settingsView").hidden) $("#settingsButton").click(); + await until(() => !$("#settingsView").hidden, "settings"); + $("[data-settings-page='migration']").click(); + await until(() => $("#importScan"), "migration page"); + if ($('#importCustomToggle') || $('#importCustomPath')) throw new Error('Custom paths must be unavailable'); + if ($('#importIncludeArchived')) throw new Error('Archive option must be absent'); + const archived = false; + await until(() => $$(".session-import-row").length === 2, "two active source sessions"); + await until(() => $$('[data-import-app] .session-import-logo').every(img => img.complete && img.naturalWidth > 0), 'platform logos loaded'); + if (!$('[data-import-app="codex"] .session-import-logo').getAttribute('src').endsWith('/codex.svg')) throw new Error('Codex logo mismatch'); + if (!$('[data-import-app="claude-code"] .session-import-logo').getAttribute('src').endsWith('/claudecode.svg')) throw new Error('Claude logo mismatch'); + if ($$('[data-import-app]').some(group => group.open)) throw new Error('Applications must start collapsed'); + if ($$('[data-import-project]').some(group => group.open)) throw new Error('Projects must start collapsed'); + for (const group of $$('[data-import-app]')) group.querySelector('summary').click(); + for (const group of $$('[data-import-project]')) group.querySelector('summary').click(); + return { archived, errors: !!$(".session-import-errors"), sources: $$(".session-import-row").map(row => row.dataset.importSource).sort() }; + `) + assert.deepEqual(scanned, { archived: false, errors: false, sources: ["claude-code", "codex"] }) + const grouping = await browser.run(` + const groups = $$('[data-import-app]').map(node => node.dataset.importApp); + let codex = $('[data-import-app="codex"]'); + const titles = $$('.session-import-section-head h1, .session-import-section-head h2').map(node => node.textContent); + const pathVisible = codex.textContent.includes(${JSON.stringify(sourcePath)}); + codex.querySelector('summary').click(); + const collapsed = !codex.open; + const search = $('.session-import-toolbar input[type="search"]'); + search.value = 'rollout-migration.jsonl'; + search.dispatchEvent(new Event('input')); + await until(() => $$('.session-import-row').length === 1, 'source path search'); + codex = $('[data-import-app="codex"]'); + const staysCollapsed = !codex.open; + codex.querySelector('summary').click(); + const expanded = codex.open; + search.value = 'no-matching-migration-session'; + search.dispatchEvent(new Event('input')); + await until(() => $$('.session-import-row').length === 0, 'empty search'); + const emptyCards = $$('[data-import-app]').length; + const emptyHints = $$('.session-import-list .empty-list').length; + search.value = ''; + search.dispatchEvent(new Event('input')); + await until(() => $$('.session-import-row').length === 2, 'cleared search'); + return { apps: groups, titles, pathVisible, collapsed, staysCollapsed, expanded, emptyCards, emptyHints }; + `) + assert.deepEqual(grouping.apps, ["codex", "claude-code"]) + assert.equal(grouping.titles.length, 2) + assert.equal(grouping.pathVisible, false) + assert.equal(grouping.collapsed, true) + assert.equal(grouping.staysCollapsed, true) + assert.equal(grouping.expanded, true) + assert.equal(grouping.emptyCards, 0) + assert.equal(grouping.emptyHints, 0) + await browser.run(` + await until(() => $$('.session-import-report').length === 2 && !$('#importScan').disabled, 'automatic preview statistics'); + const oldRow = $('.session-import-row'); + $('#importScan').click(); + await until(() => $('.session-import-row') !== oldRow && !$('#importScan').disabled, 'manual rescan completed'); + if ($$('[data-import-app]').some(group => group.open)) throw new Error('Rescan must collapse cards'); + for (const group of $$('[data-import-app]')) group.querySelector('summary').click(); + for (const group of $$('[data-import-project]')) group.querySelector('summary').click(); + for (const key of ['customRoot', 'codexRoot', 'claudeRoot', 'workbuddyRoot', 'qoderRoot']) { + const response = await fetch('/api/v1/session-imports/scan', { method:'POST', headers:{'content-type':'application/json'}, body:JSON.stringify({[key]: '/must-not-scan'}) }); + if (response.status !== 400) throw new Error('Custom path override was accepted: ' + key); + } + + if ($('.session-import-excerpt')) throw new Error('Preview body must not appear in list'); + if ($('#importSelectAll')) throw new Error('Global select all must be absent'); + const appOpen = $('[data-import-app="codex"]').open; + $('[data-import-select="codex"]').click(); + await until(() => $('[data-import-source="codex"] input[type="checkbox"]').checked, 'app selected'); + if ($('[data-import-select="codex"]').type !== 'checkbox' || $('[data-import-app="codex"]').open !== appOpen) throw new Error('Checkbox must not toggle application folding'); + const selectedRow = $('[data-import-source="codex"]'); + const unselectedRow = $('[data-import-source="claude-code"]'); + await until(() => getComputedStyle(selectedRow).backgroundColor !== getComputedStyle(unselectedRow).backgroundColor, 'selected row highlight'); + if (getComputedStyle($('.session-import-toolbar .search-input')).borderTopStyle !== 'solid') throw new Error('Search input has no border'); + if ($('[data-import-source="claude-code"] input[type="checkbox"]').checked) throw new Error('App select leaked'); + const projectSelect = $('[data-import-app="claude-code"] .session-import-select-group[data-import-select^="claude-code:"]'); + const projectOpen = projectSelect.closest('details').open; + projectSelect.click(); + await until(() => $('[data-import-source="claude-code"] input[type="checkbox"]').checked, 'project selected'); + if (projectSelect.type !== 'checkbox' || projectSelect.closest('details').open !== projectOpen) throw new Error('Project checkbox must not toggle folding'); + if ($('#importConfirm') || !$('[data-import-app="codex"] > summary [data-import-confirm="codex"]')) throw new Error('Import must be platform-scoped'); + if ($('.session-import-row .session-import-path')) throw new Error('Session paths visible'); + if ($('[data-import-project] summary .session-import-project-path')) throw new Error('Project path must be hidden'); + if (!/已选.*1|1.*selected/i.test($('[data-import-app="codex"] > summary').textContent)) throw new Error('App selected count missing'); + if (!/已选.*1|1.*selected/i.test($('[data-import-app="claude-code"] [data-import-project] > summary').textContent)) throw new Error('Project selected count missing'); + if ($('[data-import-app="codex"] input:not([type="checkbox"])')) throw new Error('Scanned path must not be editable'); + if ($('.session-import-root')) throw new Error('Source paths must be hidden'); + await until(() => !$('[data-import-confirm="codex"]').disabled, "preview completion"); + if ($('.session-import-target') || $('#importReviewSelected')) throw new Error('Obsolete target or review controls'); + const button = $('[data-import-source="codex"] button'); + button.click(); + await until(() => $('.session-import-preview-popover'), 'floating preview'); + await until(() => !button.disabled, 'preview check complete'); + if (!$('.session-import-preview-popover').textContent.includes('Migration acceptance')) throw new Error('Missing preview content'); + const panel = $('.session-import-preview-popover'); + const rect = panel.getBoundingClientRect(); + const top = document.elementFromPoint(Math.min(innerWidth - 1, rect.left + rect.width / 2), Math.min(innerHeight - 1, rect.top + 20)); + if (!panel.contains(top)) throw new Error('Preview is covered by settings'); + button.click(); + await until(() => !$('.session-import-preview-popover'), 'closed preview'); + return true; + `) + await browser.send("Emulation.setDeviceMetricsOverride", { + width: 390, height: 844, deviceScaleFactor: 1, mobile: false, + }) + try { + const layout = await browser.run(` + await new Promise(resolve => requestAnimationFrame(resolve)); + const content = $(".settings-content"); + const buttons = $$("[data-import-confirm]").map(node => node.getBoundingClientRect()); + return { + contentWidth: content.getBoundingClientRect().width, + overflowing: content.scrollWidth > content.clientWidth, + offenders: [...content.querySelectorAll('*')].filter(node => node.getBoundingClientRect().right > innerWidth).slice(0, 12).map(node => ({class: node.className, width: node.getBoundingClientRect().width})), + buttonsFit: buttons.every(rect => rect.left >= 0 && rect.right <= innerWidth), + }; + `) + assert.ok(layout.contentWidth >= 280, JSON.stringify(layout)) + assert.equal(layout.overflowing, false, JSON.stringify(layout)) + assert.equal(layout.buttonsFit, true) + } finally { + await browser.send("Emulation.setDeviceMetricsOverride", { + width: 1280, height: 900, deviceScaleFactor: 1, mobile: false, + }) + } + const updatedInput = input + [ + { type: "event_msg", timestamp, payload: { type: "token_count", info: {} } }, + { type: "response_item", timestamp, payload: { type: "message", role: "user", content: [{ type: "input_text", text: "Unreviewed source append" }] } }, + ].map(JSON.stringify).join("\n") + "\n" + await writeFile(sourcePath, updatedInput) + await browser.run(` + $('[data-import-confirm="codex"]').click(); + await until(() => $$('.session-import-result').length === 1 && !$('[data-import-confirm="claude-code"]').disabled, 'Codex imported separately'); + if ($('[data-import-source="codex"]') || $('[data-import-app="codex"]')) throw new Error('Imported Codex session or empty card still appears in source list'); + if (!$('[data-import-source="claude-code"] input').checked) throw new Error('Other platform selection was cleared'); + $('[data-import-confirm="claude-code"]').click(); + await until(() => $$(".session-import-result").length === 2, "completed imports"); + if ($('.session-import-row') || $('[data-import-app]')) throw new Error('Imported sessions must exist only in history, without empty source cards'); + await migrationScanCompleted(() => $('#importScan').click(), 'rescan with imported history'); + if ($('.session-import-row') || $('[data-import-app]') || $$('.session-import-result').length !== 2) throw new Error('Rescan restored imported source sessions or empty cards'); + const jobs = await (await fetch("/api/v1/session-imports")).json(); + if (jobs.jobs.length !== 2 || jobs.jobs.some(job => job.results.length !== 1 || job.results[0].status !== 'imported')) throw new Error('Jobs must be per-platform'); + window.migrationJob = { results: jobs.jobs.flatMap(job => job.results) }; + const history = $$('[data-import-history-app]'); + if (history.length !== 2 || history.some(group => group.querySelectorAll('.session-import-result').length !== 1)) { + throw new Error('Import history is not grouped by app'); + } + if ($('.session-import-result .session-import-path')) throw new Error('History session paths visible'); + if (history[0].open) throw new Error('Import history must start collapsed'); + for (const group of history) group.querySelector('summary').click(); + for (const group of $$('[data-import-history-project]')) group.querySelector('summary').click(); + return true; + `) + const imported = await browser.run(` + const job = window.migrationJob; + const sessions = (await (await fetch("/api/v1/sessions")).json()).sessions; + const codex = sessions.find(session => job.results.some(result => result.sessionId === session.id) && session.name.startsWith("[codex]")); + if (!codex) throw new Error("Codex import missing from native sessions"); + const snapshot = await (await fetch("/api/v1/sessions/" + codex.id)).json(); + if (!snapshot.entries.some(entry => entry.imported)) throw new Error("Missing source provenance"); + if (JSON.stringify(snapshot.entries).includes("Unreviewed source append")) throw new Error("Imported unreviewed content"); + const result = job.results.find(result => result.sessionId === codex.id); + // Grouped history renders rows per app, so select by the imported session id, not DOM order. + document.querySelector('.session-import-result[data-import-result="' + codex.id + '"] button').click(); + await until(() => $("#settingsView").hidden, "opened native import"); + await until(() => document.body.textContent.includes("Migration acceptance quokkabridge"), "imported history"); + return { id: codex.id, hasUnknown: !!$("[data-tool-state='unknown']") }; + `) + assert.equal(imported.hasUnknown, true) + assert.equal(await readFile(sourcePath, "utf8"), updatedInput) + await browser.send("Page.reload") + await browser.run(`await until(() => document.body.textContent.includes("Migration acceptance quokkabridge"), "reopened after refresh");`) + const search = await browser.run(` + const result = await (await fetch("/api/v1/search?q=quokkabridge")).json(); + return result.hits.some(hit => hit.sessionId === ${JSON.stringify(imported.id)}); + `) + assert.equal(search, true) + await browser.run(` + await migrationScanCompleted(async () => { + $('#settingsButton').click(); + $('[data-settings-page="general"]').click(); + await until(() => !$('.session-import-page'), 'general page before migration reopen'); + $('[data-settings-page="migration"]').click(); + }, 'migration reopened with persisted history'); + await until(() => $$('.session-import-result').length === 2, 'persisted history rows'); + if ($('.session-import-row') || $('[data-import-app]')) throw new Error('Reopening migration restored imported sessions or empty cards'); + $('#settingsBack').click(); + `) +}) + +test("session migration failures stay silent and preserve truthful job state", { skip }, async () => { + await browser.run(` + await until(() => $('#settingsButton') && $('#authBanner').hidden, 'authenticated page'); + if ($('#settingsView').hidden) $('#settingsButton').click(); + $('[data-settings-page="general"]').click(); + await until(() => !$('.session-import-page'), 'previous migration unmounted'); + const originalFetch = window.fetch; + const path = '/api/v1/session-imports'; + const diagnostic = '__migration_diagnostic__'; + const expiredId = 'migration-fixture-expired-session'; + const createdAt = '2026-10-22T00:00:00.000Z'; + const candidate = (id, source, title) => ({ + id, source, title, sessionId: id, cwd: ${JSON.stringify(workspace)}, + modified: createdAt, archived: false, version: 'fixture', + sourcePath: ${JSON.stringify(join(base, "mock-imports"))} + '/' + id + '.jsonl', + }); + const healthy = candidate('healthy', 'codex', 'Healthy migration fixture'); + const broken = candidate('broken', 'qoder', 'Broken migration fixture'); + const saved = { id: 'saved', createdAt, status: 'completed', total: 1, results: [{ + candidateId: 'saved-candidate', source: 'claude-code', title: 'Saved migration fixture', + status: 'imported', sessionId: expiredId, + }] }; + let history = [saved]; + let scanFailure = false; + let historyFailure = false; + let sourceErrors = true; + let brokenPreview = true; + let healthyPreviewFailure = false; + let startFailure = true; + let pollFailure = true; + let running; + const calls = { history: 0, scan: 0, previews: [], start: [], cancel: 0, poll: 0, open: 0 }; + const leaks = new Set(); + const inspect = (node) => { + if ((node.textContent ?? '').includes(diagnostic)) leaks.add('technical diagnostic entered DOM'); + }; + const observer = new MutationObserver(records => { + inspect(document.body); + for (const record of records) { + inspect(record.target); + for (const node of record.addedNodes) inspect(node); + } + }); + observer.observe(document.body, { subtree: true, childList: true, characterData: true }); + const response = (body, status = 200) => new Response(JSON.stringify(body), { + status, headers: { 'content-type': 'application/json' }, + }); + const failure = stage => response({ error: diagnostic + stage }, 500); + window.fetch = async (input, init) => { + const url = new URL(typeof input === 'string' ? input : input.url, location.href); + const method = init?.method ?? (input instanceof Request ? input.method : 'GET'); + if (url.origin !== location.origin) return originalFetch(input, init); + if (url.pathname === '/api/v1/sessions/' + expiredId && method === 'GET') { + calls.open++; + return failure('open'); + } + if (url.pathname !== path && !url.pathname.startsWith(path + '/')) return originalFetch(input, init); + if (url.pathname === path && method === 'GET') { + calls.history++; + return historyFailure ? failure('history') : response({ jobs: history }); + } + if (url.pathname === path + '/scan' && method === 'POST') { + calls.scan++; + return scanFailure ? failure('scan') : response({ + roots: { codex: '/fixture/codex', qoder: '/fixture/qoder', 'claude-code': '/fixture/claude' }, + candidates: [healthy, broken], + errors: sourceErrors ? [{ source: 'claude-code', message: diagnostic + 'source' }] : [], + }); + } + if (url.pathname === path + '/preview' && method === 'POST') { + const { candidateId } = JSON.parse(init.body); + calls.previews.push(candidateId); + if ((candidateId === broken.id && brokenPreview) || (candidateId === healthy.id && healthyPreviewFailure)) return failure('preview'); + const item = candidateId === healthy.id ? healthy : broken; + return response({ ...item, fingerprint: 'fixture-' + item.id, messageCount: 2, toolCount: 1, + warnings: [{ code: 'unsupported_content', count: 73519 }], excerpt: item.title + ' preview', + }); + } + if (url.pathname === path && method === 'POST') { + calls.start.push(JSON.parse(init.body)); + if (startFailure) return failure('start'); + running = { id: 'fixture-running', createdAt, status: 'running', total: 1, results: [] }; + history = [running, ...history]; + return response(running); + } + if (url.pathname === path + '/fixture-running' && method === 'DELETE') { + calls.cancel++; + return failure('cancel'); + } + if (url.pathname === path + '/fixture-running' && method === 'GET') { + calls.poll++; + return pollFailure ? failure('poll') : response(running); + } + throw new Error('Unexpected migration fixture request: ' + method + ' ' + url.pathname); + }; + try { + const painted = async () => { + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }; + const silent = async stage => { + await painted(); + inspect(document.body); + if (leaks.size) throw new Error(stage + ': ' + [...leaks].join(', ')); + if ($('.session-import-error, .session-import-errors, .session-import-row-failure, .session-import-list .empty-list')) throw new Error(stage + ': diagnostic or empty-result UI remains'); + if (!$('#errorToast').hidden) throw new Error(stage + ': unexpected error toast'); + if ($$('.session-import-report').some(report => report.querySelectorAll('span').length !== 1)) throw new Error(stage + ': preview compatibility warnings remain'); + }; + const expand = () => { + for (const group of $$('[data-import-app], [data-import-project], [data-import-history-app], [data-import-history-project]')) group.open = true; + }; + const ready = async label => { + await until(() => $('#importScan') && !$('#importScan').disabled, label); + await painted(); + }; + const rescan = async label => { + const previous = calls.history; + $('#importScan').click(); + await until(() => calls.history > previous, label + ' request'); + await ready(label); + }; + const savedRemains = () => { + if (!$('[data-import-result="' + expiredId + '"]')) throw new Error('Successful saved history disappeared'); + }; + const healthyOnly = () => { + const rows = $$('.session-import-row'); + if (rows.length !== 1 || rows[0].querySelector('strong').textContent !== healthy.title) throw new Error('Only the healthy candidate should be displayed'); + if ($('[data-import-app="qoder"], [data-import-app="claude-code"]')) throw new Error('Failed or empty source card remains'); + if (rows[0].querySelector('input').disabled) throw new Error('Healthy candidate is not selectable'); + }; + $('[data-settings-page="migration"]').click(); + await until(() => calls.scan === 1 && calls.previews.length === 2, 'injected scan and previews'); + await ready('initial scan complete'); + await silent('per-source scan errors and partial preview failure'); + healthyOnly(); + savedRemains(); + expand(); + const search = $('.session-import-toolbar input[type="search"]'); + search.value = 'no-matching-fixture'; + search.dispatchEvent(new Event('input')); + await until(() => !$('.session-import-row'), 'no search matches'); + if ($('[data-import-app]')) throw new Error('Empty search left an application card'); + await silent('empty search'); + savedRemains(); + search.value = ''; + search.dispatchEvent(new Event('input')); + await until(() => $('.session-import-row'), 'healthy search restored'); + healthyOnly(); + sourceErrors = false; + brokenPreview = false; + await rescan('successful rescan recovery'); + if ($$('.session-import-row').length !== 2) throw new Error('Rescan did not restore recovered candidate'); + await silent('recovered preview'); + expand(); + const row = $$('.session-import-row').find(node => node.querySelector('strong').textContent === healthy.title); + row.querySelector('input').click(); + row.querySelector('button').click(); + await until(() => $('.session-import-preview-popover') && !row.querySelector('button').disabled, 'healthy preview opened'); + row.querySelector('button').click(); + await until(() => !$('.session-import-preview-popover'), 'healthy preview closed'); + healthyPreviewFailure = true; + row.querySelector('button').click(); + await until(() => !$('#importScan').disabled && !row.isConnected, 'previously selected preview removed after failure'); + if ($('.session-import-preview-popover')) throw new Error('Failed preview left stale content open'); + if ($('[data-import-confirm="codex"]')) throw new Error('Failed selected candidate can still be submitted'); + await silent('selected preview failure'); + healthyPreviewFailure = false; + await rescan('restore selected preview'); + saved.results[0].warning = diagnostic + 'saved-warning'; + const failedHistory = { id: 'persisted-failed', createdAt, status: 'completed', total: 1, results: [{ + candidateId: 'persisted-failed-candidate', source: 'qoder', title: 'Failed saved fixture', + status: 'failed', error: diagnostic + 'result-error', warning: diagnostic + 'result-warning', + }] }; + history = [saved, failedHistory]; + await rescan('persistent result diagnostics'); + const failedStatus = () => { + const result = $('[data-import-result="persisted-failed-candidate"]'); + if (!result || !/未导入|Not imported/i.test(result.textContent)) throw new Error('Real failed history must remain as Not imported'); + if (result.querySelector('button')) throw new Error('Failed history must not offer a successful session'); + }; + failedStatus(); + await silent('persistent result error and warning'); + scanFailure = true; + await rescan('whole scan failure'); + if ($('.session-import-row, [data-import-app]')) throw new Error('Failed scan retained source candidates or empty cards'); + savedRemains(); + failedStatus(); + await silent('whole scan failure'); + scanFailure = false; + await rescan('scan recovery'); + if ($$('.session-import-row').length !== 2) throw new Error('Healthy candidates did not recover after scan failure'); + historyFailure = true; + const scansBeforeHistoryFailure = calls.scan; + await rescan('history GET failure'); + if (calls.scan !== scansBeforeHistoryFailure) throw new Error('Scan continued after failed history read'); + if ($('.session-import-row, [data-import-app]')) throw new Error('Failed history read retained candidates'); + savedRemains(); + failedStatus(); + await silent('history GET failure'); + historyFailure = false; + await rescan('history recovery'); + if ($$('.session-import-row').length !== 2) throw new Error('History recovery lost healthy candidates'); + expand(); + $('[data-import-select="codex"]').click(); + await until(() => !$('[data-import-confirm="codex"]').disabled, 'healthy selection ready'); + const resultsBeforeStart = $$('.session-import-result').length; + $('[data-import-confirm="codex"]').click(); + await until(() => calls.start.length === 1 && !$('[data-import-confirm="codex"]').disabled, 'start POST failure settled'); + if ($$('.session-import-result').length !== resultsBeforeStart) throw new Error('Failed submission fabricated an import result'); + if (!$('[data-import-source="codex"] input').checked) throw new Error('Failed submission discarded retryable selection'); + if ($('.session-import-history > header button')) throw new Error('Failed submission fabricated a running job'); + if (calls.start[0].selections.length !== 1 || calls.start[0].selections[0].candidateId !== healthy.id) throw new Error('Submission included an unselected or failed candidate'); + await silent('start POST failure'); + startFailure = false; + $('[data-import-confirm="codex"]').click(); + await until(() => calls.start.length === 2 && calls.poll >= 2, 'running job and failed poll retry'); + const stillRunning = () => { + if (!$('#importScan').disabled || !$('.session-import-history > header button')) throw new Error('Uncertain running job became completed or cancelled'); + if (!/正在导入|Importing/i.test($('.session-import-history > .settings-page-desc').textContent)) throw new Error('Last known running status was lost'); + if ($$('.session-import-result').length !== resultsBeforeStart) throw new Error('Uncertain running job fabricated a result'); + savedRemains(); + failedStatus(); + }; + stillRunning(); + await silent('poll GET failure'); + const pollsBeforeCancel = calls.poll; + $('.session-import-history > header button').click(); + await until(() => calls.cancel === 1 && calls.poll > pollsBeforeCancel, 'cancel DELETE failure and subsequent poll'); + stillRunning(); + if (calls.start.length !== 2 || calls.cancel !== 1) throw new Error('Failed mutation was automatically retried'); + await silent('cancel DELETE failure'); + running = { ...running, status: 'completed', results: [{ + candidateId: healthy.id, source: healthy.source, title: healthy.title, cwd: healthy.cwd, + status: 'failed', error: diagnostic + 'job-result-error', warning: diagnostic + 'job-result-warning', + }] }; + history = [running, ...history.filter(job => job.id !== running.id)]; + pollFailure = false; + await until(() => !$('#importScan').disabled && $('[data-import-result="healthy"]'), 'poll recovery with real failed result'); + const failedImport = $('[data-import-result="healthy"]'); + if (!/未导入|Not imported/i.test(failedImport.textContent) || failedImport.querySelector('button')) throw new Error('Failed job result masquerades as an imported session'); + if ($('.session-import-history > header button')) throw new Error('Completed job still offers cancellation'); + if (!$('[data-import-source="codex"] input').checked) throw new Error('Failed result lost retryable selection'); + if (calls.start.length !== 2 || calls.cancel !== 1) throw new Error('Mutation retried during poll recovery'); + await silent('poll recovery and real failed result'); + await rescan('rescan after failed import'); + if ($$('.session-import-row').length !== 2) throw new Error('Failed import incorrectly hid healthy candidate as imported'); + savedRemains(); + expand(); + $('[data-import-result="' + expiredId + '"] button').click(); + await until(() => calls.open === 1 && $('#settingsView').hidden, 'failed history session open'); + await silent('invalid imported session open'); + } finally { + try { + $('[data-settings-page="general"]').click(); + await until(() => !$('.session-import-page'), 'migration fixture unmounted'); + } finally { + observer.disconnect(); + window.fetch = originalFetch; + if (!$('#settingsView').hidden) $('#settingsBack').click(); + } + } + `) +}) + let base let workspace let extra @@ -374,6 +864,10 @@ before(async () => { cwd: webRoot, env: { ...process.env, + HOME: join(base, "scan-home"), + USERPROFILE: join(base, "scan-home"), + CODEX_HOME: join(base, "migration-codex"), + CLAUDE_CONFIG_DIR: join(base, "migration-claude"), TSX_TSCONFIG_PATH: join(webRoot, "..", "..", "tsconfig.json"), STEPCODE_WEB_PORT: String(port), STEPCODE_WEB_CWD: workspace, @@ -3517,3 +4011,119 @@ test("forking from the middle of a conversation branches there and leaves the pa const after = await readFile(join(base, "agent", "sessions", "--e2e-fork-here--", sessionId + ".jsonl"), "utf8") assert.ok(after.startsWith(before), "the parent's transcript kept every original record") }) + +test("WorkBuddy and Qoder sessions are identified and imported through the migration UI", { skip }, async () => { + const root = join(base, "scan-home") + const workbuddyRoot = join(root, ".workbuddy", "projects") + const qoderRoot = join(root, ".qoder", "projects") + await mkdir(workbuddyRoot, { recursive: true }) + await mkdir(qoderRoot, { recursive: true }) + const timestamp = "2026-10-26T00:00:00.000Z" + await writeFile(join(workbuddyRoot, "workbuddy.jsonl"), [ + { type: "session-meta", sessionId: "wb-ui" }, + { type: "message", id: "wb-u", sessionId: "wb-ui", cwd: workspace, role: "user", timestamp, content: [{ type: "input_text", text: "WorkBuddy fixture" }] }, + { type: "ai-title", sessionId: "wb-ui", aiTitle: "WorkBuddy fixture" }, + ].map(JSON.stringify).join("\n") + "\n") + await writeFile(join(qoderRoot, "qoder.jsonl"), [ + { type: "workspace-directories", sessionId: "q-ui" }, + { type: "user", sessionId: "q-ui", cwd: workspace, uuid: "q-u", parentUuid: null, timestamp, message: { role: "user", content: "Qoder fixture" } }, + { type: "active-leaf", sessionId: "q-ui", leafUuid: "q-u" }, + ].map(JSON.stringify).join("\n") + "\n") + const imported = await browser.run(` + if ($('#settingsView').hidden) $('#settingsButton').click(); + $('[data-settings-page="migration"]').click(); + await until(() => $('#importScan'), 'migration page ready'); + if ($('#importCustomToggle') || $('#importCustomPath')) throw new Error('Custom path controls must be absent'); + await until(() => $('[data-import-app="workbuddy"] .session-import-report') && $('[data-import-app="qoder"] .session-import-report') && !$('#importScan').disabled, 'new source previews'); + await until(() => $$('[data-import-app] .session-import-logo').every(img => img.complete && img.naturalWidth > 0), 'new source logos'); + if (!$('[data-import-app="workbuddy"] .session-import-logo').getAttribute('src').endsWith('/workbuddy.svg')) throw new Error('WorkBuddy logo mismatch'); + if (!$('[data-import-app="qoder"] .session-import-logo').getAttribute('src').endsWith('/qoder-dark.svg')) throw new Error('Qoder logo mismatch'); + if ($$('[data-import-app]').some(group => group.open)) throw new Error('New source cards must start collapsed'); + for (const group of $$('[data-import-app]')) group.querySelector('summary').click(); + for (const group of $$('[data-import-project]')) group.querySelector('summary').click(); + if (!$$('.session-import-row strong').some(node => node.textContent.includes('WorkBuddy fixture'))) throw new Error('Missing WorkBuddy title'); + $('[data-import-select="workbuddy"]').click(); + $('[data-import-select="qoder"]').click(); + await until(() => !$('[data-import-confirm="workbuddy"]').disabled, 'new source confirmation'); + $('[data-import-confirm="workbuddy"]').click(); + await until(() => $('[data-import-history-app="workbuddy"] .session-import-result') && !$('[data-import-confirm="qoder"]').disabled, 'WorkBuddy imported'); + if ($('[data-import-source="workbuddy"]') || $('[data-import-app="workbuddy"]')) throw new Error('Imported WorkBuddy session or empty card still appears in source list'); + if (!$('[data-import-source="qoder"] input').checked) throw new Error('Qoder selection must remain'); + $('[data-import-confirm="qoder"]').click(); + await until(() => $('[data-import-history-app="workbuddy"] .session-import-result') && $('[data-import-history-app="qoder"] .session-import-result'), 'new source import history'); + if (['workbuddy', 'qoder'].some(source => $('[data-import-source="' + source + '"]') || $('[data-import-app="' + source + '"]'))) throw new Error('New source imports must exist only in history, without empty cards'); + const jobs = (await (await fetch('/api/v1/session-imports')).json()).jobs; + const results = jobs.flatMap(job => job.results).filter(result => ['workbuddy', 'qoder'].includes(result.source)); + for (const result of results) { + const snapshot = await (await fetch('/api/v1/sessions/' + result.sessionId)).json(); + if (JSON.stringify(snapshot.entries).includes('')) throw new Error('User wrapper leaked into imported entries'); + } + const workbuddy = results.find(result => result.source === 'workbuddy'); + document.querySelector('.session-import-result[data-import-result="' + workbuddy.sessionId + '"] button').click(); + await until(() => $('#settingsView').hidden && $('.message.user')?.dataset.content === 'WorkBuddy fixture', 'clean imported user message'); + return results.map(result => ({ source: result.source, status: result.status })); + `) + assert.deepEqual(imported.sort((a, b) => a.source.localeCompare(b.source)), [{ source: "qoder", status: "imported" }, { source: "workbuddy", status: "imported" }]) +}) + +test("new conversation offers a lightweight shortcut to session migration", { skip }, async () => { + await browser.send("Emulation.setEmulatedMedia", { features: [{ name: "prefers-reduced-motion", value: "reduce" }] }) + const report = await browser.run(` + if (!$("#settingsView").hidden) $("#settingsBack").click(); + await until(() => $("#newSession"), "new conversation"); + $("#newSession").click(); + const link = await until(() => $("#emptySessionImport"), "empty import shortcut"); + return { text: link.textContent.trim(), title: $("#stream .empty-title")?.textContent, mark: !!$("#stream .empty-mark .icon") }; + `) + assert.equal(report.text, "从其他AI应用导入会话") + assert.equal(report.title, "今天想推进什么?") + assert.equal(report.mark, true) + const qaDir = process.env.STEPCODE_WEB_QA_DIR + if (qaDir) await mkdir(qaDir, { recursive: true }) + try { + for (const width of [1280, 390]) { + await browser.send("Emulation.setDeviceMetricsOverride", { width, height: 900, deviceScaleFactor: 1, mobile: false }) + if (width === 390) await browser.run(` + $('#sidebarToggle').click(); + await until(() => $('.app').classList.contains('sidebar-hidden'), 'sidebar collapsed for narrow layout'); + `) + const layout = await browser.run(` + await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const mark = $('#stream .empty-mark').getBoundingClientRect(); + const title = $('#stream .empty-title').getBoundingClientRect(); + const column = $('#stream .stream-inner').getBoundingClientRect(); + const button = $('#emptySessionImport').getBoundingClientRect(); + const centre = column.left + column.width / 2; + return { + headingCentreDelta: Math.abs((mark.left + title.right) / 2 - centre), + buttonCentreDelta: Math.abs(button.left + button.width / 2 - centre), + belowHeading: button.top >= Math.max(mark.bottom, title.bottom) + 8, + buttonFits: button.left >= column.left && button.right <= column.right, + hasSubtitle: !!$('#emptySessionImport small'), + horizontalOverflow: $('#stream').scrollWidth > $('#stream').clientWidth + }; + `) + assert.ok(layout.headingCentreDelta < 2, JSON.stringify({ width, ...layout })) + assert.ok(layout.buttonCentreDelta < 2, JSON.stringify({ width, ...layout })) + assert.equal(layout.belowHeading, true, JSON.stringify({ width, ...layout })) + assert.equal(layout.buttonFits, true, JSON.stringify({ width, ...layout })) + assert.equal(layout.hasSubtitle, false) + assert.equal(layout.horizontalOverflow, false, JSON.stringify({ width, ...layout })) + if (qaDir) { + const shot = await browser.send("Page.captureScreenshot", { format: "png" }) + await writeFile(join(qaDir, `empty-migration-${width}.png`), Buffer.from(shot.data, "base64")) + } + } + await browser.run(` + $('#emptySessionImport').click(); + await until(() => !$('#settingsView').hidden && $('[data-page="migration"]'), 'migration settings'); + if (!$('#importScan')) throw new Error('Migration page did not open'); + $('#settingsBack').click(); + `) + } finally { + await browser.send("Emulation.setDeviceMetricsOverride", { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false }) + await browser.run(`if ($('.app').classList.contains('sidebar-hidden')) $('#sidebarShow').click();`) + await browser.send("Emulation.setEmulatedMedia", { features: [] }) + } + if (qaDir) console.log(`Empty migration layout screenshots: ${qaDir}`) +}) diff --git a/apps/web/test/turn-changes.test.ts b/apps/web/test/turn-changes.test.ts index 8fa859a8..b1ece4f6 100644 --- a/apps/web/test/turn-changes.test.ts +++ b/apps/web/test/turn-changes.test.ts @@ -645,6 +645,29 @@ test("turn versions replay from a recorded write, whatever happened to the file }); }); +test("imported file writes cannot replace native delivery versions", async () => { + await withRoot(async (root) => { + const s = session(root); + ask(s, "native-write"); + await stepWrite(s, "native-call", "index.html", "

native workspace

\n"); + const historical = [ + entry("imported-user", { role: "user", content: "Historical task" }), + entry("imported-call", { + role: "assistant", + content: [{ type: "toolCall", id: "historical-write", name: "write_file", arguments: { path: "index.html", content: "

historical evidence

\n" } }], + }), + entry("imported-result", { role: "toolResult", toolCallId: "historical-write", isError: false }), + ].map((item) => ({ ...item, imported: { source: "workbuddy" as const, sessionId: "foreign", itemId: item.id } })); + s.entries.push(...historical); + ask(s, "native-reference"); + assert.deepEqual(turnChanges(s.entries, "imported-user"), { changes: [], skipped: [] }); + assert.deepEqual(await fileAtTurnEnd(s.entries, "native-reference", "index.html", root), { exact: true }); + assert.equal(await content(join(root, "index.html")), "

native workspace

\n"); + await unlink(join(root, "index.html")); + assert.deepEqual(await fileAtTurnEnd(s.entries, "native-reference", "index.html", root), { text: "

native workspace

\n", exact: true }); + }); +}); + test("a forward patch applies the recorded change and refuses content that moved on", () => { assert.equal(applyPatch("a\nb\nc\n", PATCHES.edit), "a\nB\nc\nd\n"); assert.equal(applyPatch("a\nb", PATCHES.appendNoEol), "a\nb\nc"); diff --git a/packages/coding-agent/AGENTS.md b/packages/coding-agent/AGENTS.md index 6f9728e0..894a2f75 100644 --- a/packages/coding-agent/AGENTS.md +++ b/packages/coding-agent/AGENTS.md @@ -35,6 +35,13 @@ Inherits the [root rules](../../AGENTS.md). This package is `@step-harness/codin Core provides mechanisms that product features compose. Do not put a feature's state machine into tool loading, session storage, or the generic Agent Loop. +`src/core/session-import/` owns offline foreign-session discovery, adapters and +atomic import jobs. Keep source reads separate from live session opening. Preserve +import provenance, exclude historical usage/model selection, and project source +tools as evidence rather than executable calls. See `docs/session-import.md`. +Keep explicit failed/unknown tool outcomes and supported compaction attachments +in continuation context; discovery must not truncate a metadata-bearing JSONL record. + ### src/features: concrete features and orchestration | Feature | Main locations | Contents | diff --git a/packages/coding-agent/docs/session-format.md b/packages/coding-agent/docs/session-format.md index 144fb34f..8535df81 100644 --- a/packages/coding-agent/docs/session-format.md +++ b/packages/coding-agent/docs/session-format.md @@ -2,6 +2,14 @@ Sessions are stored as JSONL (JSON Lines) files. Each line is a JSON object with a `type` field. Session entries form a tree structure via `id`/`parentId` fields, enabling in-place branching without creating new files. +Imported history may carry optional `imported` provenance on entries. It is +excluded from native usage totals and source model selection; historical tools +are projected as text evidence with explicit completed, failed or unknown +outcomes for new requests. Imported compaction attachments use provenance-marked +`custom_message` entries (`stepcode.imported-compaction-attachments`) so images +and captions survive context projection without adding source user turns. +See [offline session import](session-import.md). + ## File Location ``` diff --git a/packages/coding-agent/docs/session-import.md b/packages/coding-agent/docs/session-import.md new file mode 100644 index 00000000..3cacabe8 --- /dev/null +++ b/packages/coding-agent/docs/session-import.md @@ -0,0 +1,164 @@ +# Offline Session Import + +`SessionImportService` discovers and imports local Codex, Claude Code, WorkBuddy and Qoder history +without starting either source agent or invoking a model. The Web settings page +uses this same service. This is history migration, not restoration of source +processes, approvals, credentials, filesystem checkpoints or hidden reasoning. + +## Lifecycle + +Construct the service with an explicit `agentDir`, optional `sessionRoot`, and +an optional `onPersisted` index notification callback. Hosts must call `close()` +and await it before closing their index. + +1. `scan({ customRoot?, codexRoot?, claudeRoot?, workbuddyRoot?, qoderRoot?, includeArchived? })` returns opaque + candidate IDs and per-source errors. Default roots honor `CODEX_HOME` and + `CLAUDE_CONFIG_DIR`; otherwise they are `~/.codex` and + `~/.claude/projects`. Codex archives and child sessions are excluded by + default. Claude has no universally recognized archive flag; only explicit + supported metadata is interpreted, never inactivity. +2. `preview(candidateId)` reads a stable snapshot and returns its fingerprint, + bounded text excerpt, counts and compatibility warnings. It retains an + immutable, in-memory copy of the reviewed messages and embedded attachments. + It does not create or open a target session. +3. `start([{ candidateId, fingerprint, cwd }])` accepts at most 100 selections + and returns a job. `cwd` must resolve to an existing absolute directory. +4. `getJob(id)` and `listJobs()` return durable, per-session outcomes. + `cancel(id)` stops remaining work between sessions; poll for settlement. + +An automatic scan replaces prior candidate capabilities. A `customRoot` scan appends +new candidates, deduplicating the same source and file path while preserving existing +candidate IDs and reviewed snapshots. It returns newly discovered candidates only, with `existingCount` reporting matched +sessions already in the list so a duplicate-only scan is not mistaken for no matches. +Submitted jobs retain their own +reviewed snapshots and selection copies. Import uses exactly the reviewed +snapshot, even if the source continues writing or its attachments change. +Preview again to include later source changes. Unsubmitted previews expire after +15 minutes or host restart and can be evicted under cache pressure. The cache +holds at most 256 MiB of serialized UTF-16 data; submitted snapshots are pinned +until their jobs settle. Expired or evicted previews require another preview, +never an implicit re-read at import time. +An identical source ID and snapshot generates the same target ID, so +reimport never overwrites an existing target, including one continued in StepCode. +A changed snapshot generates a new copy. Target publication uses an exclusive +temporary file and an atomic, non-overwriting hard link on the same filesystem. +Filesystems without hard-link support fail visibly. Completed imports remain +when a batch is cancelled. A restarted host marks unfinished journals interrupted; +re-scanning and retrying safely skips already published snapshots. +Result titles and selected destination `cwd` survive host restart. Every +per-selection imported, skipped or failed result includes the selected absolute +`cwd`, even when that directory could not be opened; it is not the source project +or a claim that a skipped existing session has that cwd. A batch-level journal +failure may have no destination. Persisted cwd values must be absolute strings +without NUL characters; invalid values reject the journal. + +Older journals can recover missing cwd and title from at most the first 1 MiB of +a native session, only when its resolved path is inside `sessionRoot/imported`. +Recovery uses the native session header for cwd, `session_info` name or first user +text for title, and validated entry provenance for source. Existing metadata is +preserved. Missing, inaccessible, escaped or out-of-window metadata remains +unknown; recovery neither opens a live session nor rewrites native history. +If the index callback fails after publication, the result remains imported (or +skipped), with a warning rather than a false failure; retrying also re-notifies +the index. + +## Desktop scanning + +Opening the migration page automatically scans the supported applications' default +projects directories. The rescan action repeats that scan; application and project +cards start collapsed. The desktop HTTP scan endpoint accepts only `includeArchived` +and rejects arbitrary source-directory overrides. There is no custom path entry in +the desktop UI. The SDK directory options above remain available for trusted +programmatic callers and isolated tests; they are not desktop input fields. + +The desktop list contains only unimported candidates with a successful preview +that match the search text. Missing sources, failed discovery or previews, and +searches with no matches have no row or error hint. Technical errors and compatibility +warnings remain available through the SDK and persisted import metadata, but +are not rendered as desktop diagnostics. Failed imports retain their actual +status and appear as "Not imported"; presentation never changes a failure into +success. Polling failures retain the last known status, and failed mutations +are not retried automatically. Unavailable imported-session links suppress +technical toast messages; global authentication-expiry handling is unchanged. + +## WorkBuddy and Qoder + +Default roots are `~/.workbuddy/projects` and `~/.qoder/projects`. A custom root +can contain a mixture of supported formats; Qoder-specific header records prevent +its Claude-shaped messages from being misidentified as Claude Code. WorkBuddy +session status files under `sessions/` are not transcripts. + +The adapters were verified against local JSONL structures on macOS. WorkBuddy +message blocks and independent tool calls/results are migrated with source IDs; +completed, failed and unknown tool outcomes remain distinct. Unsupported opaque +reasoning produces a warning rather than being guessed or decrypted. Qoder uses +the final `active-leaf` parent chain rather than the most recently stored branch. +Its runtime configuration and attachment/control envelopes are not activated; +unmigratable attachment records produce compatibility warnings. Other application +versions, encrypted/cloud-only history and restoration of live processes are not +guaranteed. Qcoder was interpreted as the locally installed Qoder product. + +## Compatibility + +The adapters support Codex response-item JSONL, including paginated-mode rollout +records, independent function calls/results, source compaction summaries and +rollback events; and Claude message chains, content blocks and tool-result +envelopes. Tool names are retained, not renamed to apparently equivalent native +tools. Unknown Codex record formats fail rather than silently declare success. +Unsupported Codex item types remain quoted historical text and produce warnings. +Claude non-message metadata is not replayed. Its source compaction boundaries +are not reconstructed; only messages reachable from the selected parent chain +are migrated. Real Claude installations and versions outside the fixtures still +require acceptance testing; this is not a lossless source-state backup. + +Imported entries carry an optional `imported` provenance object. Native context +projection converts historical tool calls/results to text evidence; it does not +restore their execution protocol. Tool results explicitly carry completed, +failed or unknown status into new requests; absent source outcome information +is never interpreted as success. Imported assistant messages do not select the +new session's model. Usage ledgers exclude imported entries; source numeric usage, +when available, remains provenance, not newly incurred StepCode usage. Internal +zero usage required by the native assistant wire type is not a source measurement. + +The desktop groups consecutive ordinary historical tool calls into its existing +folded process rows, including calls whose outcomes are unknown. Groups with +unconfirmed outcomes show a neutral recorded-call count rather than completion. +They never acquire a running state from a later live turn. Individual call +statuses and outputs remain readable after expansion; explicit failures and +normal turn boundaries stay separate. This is a presentation change and applies +to existing imports without rewriting or reimporting them. + +Source system/developer messages and host control envelopes are not activated. +Encrypted reasoning is omitted with a warning. Plain reasoning summaries can be +retained as historical text. Valid source compaction summaries affect only the +request context; preceding imported entries remain readable. Supported images +in Codex `replacement_history` survive as provenance-marked +`stepcode.imported-compaction-attachments` custom messages after the compaction. +These retain captions and enter model context without introducing synthetic user +turns into source rollback accounting. Unsupported or missing retained images +produce the same attachment warnings as ordinary messages. Continuing a long +session may invoke StepCode's normal model-backed compaction, independently of +the offline import operation. + +Inline supported images and image files contained in the selected source root +are embedded in the target. Local images are capped at 20 MiB each and 32 MiB +total, with MIME detection and fingerprinting. External paths, missing images and +remote URLs are not fetched and are reported. Project files are not copied. +Source sessions are capped at 128 MiB; discovery at 5,000 candidates and 16 +directory levels. Discovery inspects the first 1 MiB for readable titles without +full adapter parsing, attachment loading, preview caching or source writes. It +uses explicit Claude `custom-title`/`summary` records or Codex metadata `title` / +`thread_name` (the Codex title index takes precedence), otherwise normalized first +user text, then a project/source plus ID fallback. Titles are capped at 120 +characters. Fallback scan titles do not override the fully parsed preview title. +Discovery decodes only complete JSONL records. Until identity metadata is found, +it retains the existing complete-record read behavior within the 128 MiB source +cap, including metadata-bearing records over 1 MiB. A final newline is not +required. Other titles beyond the window require preview. +Symlinks are not traversed during discovery. +Codex date subdirectories can be selected directly. A selected path inside +`archived_sessions` is still excluded unless archive inclusion is enabled. + +Web endpoints are specified in [the host OpenAPI contract](../../../apps/web/openapi.yaml). +They use the host's existing cookie and Origin boundary. The browser always +scans the **host** machine, not a remote browser device. diff --git a/packages/coding-agent/src/core/session-import/content.ts b/packages/coding-agent/src/core/session-import/content.ts new file mode 100644 index 00000000..1fa2dccd --- /dev/null +++ b/packages/coding-agent/src/core/session-import/content.ts @@ -0,0 +1,60 @@ +const CONTROL_TAGS = [ + "system-reminder", + "environment_context", + "permissions instructions", + "user_instructions", + "developer_instructions", + "turn_aborted", + "request_id", + "turn_id", + "model", + "command-name", + "command-message", + "command-args", + "local-command-caveat", + "local-command-stdout", + "local-command-stderr", +]; +const PREFIX = new RegExp(`^\\s*<(${CONTROL_TAGS.join("|")})(?:\\s[^>]*)?>`); + +function unwrapUserQuery(value: string): string { + const start = /^\s*]*)?>/.exec(value); + if (!start) return value; + const tokens = /<\/?user_query(?:\s[^>]*)?>/g; + tokens.lastIndex = start[0].length; + let depth = 1; + for (let token = tokens.exec(value); token; token = tokens.exec(value)) { + depth += token[0].startsWith("]*)?>`, "g"); + tokens.lastIndex = first[0].length; + let depth = 1; + let end = -1; + for (let token = tokens.exec(text); token; token = tokens.exec(text)) { + depth += token[0].startsWith("; + +export function record(value: unknown): RecordValue { + return value && typeof value === "object" && !Array.isArray(value) ? (value as RecordValue) : {}; +} + +function text(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function list(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function time(value: unknown): string { + const parsed = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : Number.NaN; + return Number.isFinite(parsed) ? new Date(parsed).toISOString() : new Date(0).toISOString(); +} + +const EMPTY_USAGE: AssistantMessage["usage"] = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + +/** Parse snapshots only. Never pass a foreign source through SessionManager.open. */ +export function parseForeignSession(source: ImportSource, input: string): ParsedImport { + const records: RecordValue[] = []; + for (const line of input.split(/\r?\n/u)) { + if (!line.trim()) continue; + try { + const value: unknown = JSON.parse(line); + const parsed = record(value); + if (typeof parsed.type !== "string") throw new Error("Missing record type"); + records.push(parsed); + } catch { + throw new Error("Invalid JSONL snapshot; wait for the source to finish writing and scan again"); + } + } + const meta = source === "codex" ? record(records.find((r) => r.type === "session_meta")?.payload) : {}; + const first = + source === "codex" + ? meta + : (records.find((r) => typeof r.sessionId === "string" && typeof r.cwd === "string") ?? + records.find((r) => typeof r.sessionId === "string") ?? + {}); + const sessionId = text(source === "codex" ? first.id : first.sessionId); + if (!sessionId) throw new Error("Source session ID is missing"); + const result: ParsedImport = { + sessionId, + cwd: text(first.cwd), + title: "", + version: text(source === "codex" ? first.cli_version : first.version) || "unknown", + archived: records.some((r) => r.archived === true || record(r.payload).archived === true), + child: + source === "codex" + ? !!record(first.source).subagent || !!record(first.thread_source).subagent + : first.isSidechain === true || typeof first.parentAgentId === "string", + entries: [], + warnings: [], + }; + const warnings = new Map(); + const warn = (code: string) => warnings.set(code, (warnings.get(code) ?? 0) + 1); + let activeClaudeIds: Set | undefined; + if (source === "claude-code" || source === "qoder") { + const byId = new Map(records.filter((r) => typeof r.uuid === "string").map((r) => [text(r.uuid), r])); + const messages = records.filter((r) => (r.type === "user" || r.type === "assistant") && r.isSidechain !== true); + if (messages.some((r) => typeof r.parentUuid === "string")) { + activeClaudeIds = new Set(); + const leaf = source === "qoder" ? text(records.filter((r) => r.type === "active-leaf").at(-1)?.leafUuid) : ""; + let current = leaf ? byId.get(leaf) : messages.at(-1); + if (leaf && !current) throw new Error("Qoder active leaf is missing from the source snapshot"); + while (current && typeof current.uuid === "string") { + if (activeClaudeIds.has(current.uuid)) throw new Error("Cycle in source message chain"); + activeClaudeIds.add(current.uuid); + if (typeof current.parentUuid !== "string") break; + const parent = byId.get(current.parentUuid); + if (!parent) { + warn("branch_parent_missing"); + break; + } + current = parent; + } + } + } + let parentId: string | null = null; + let sequence = 0; + const seen = new Set(); + const append = (entry: SessionEntry, itemId: string, usage?: unknown) => { + if (seen.has(itemId)) return; + seen.add(itemId); + entry.id = randomBytes(8).toString("hex"); + entry.parentId = parentId; + const historicalUsage = Object.fromEntries( + Object.entries(record(usage)).filter(([, n]) => typeof n === "number" && Number.isSafeInteger(n) && n >= 0), + ); + entry.imported = { + source, + sessionId, + itemId, + ...(Object.keys(historicalUsage).length ? { historicalUsage: historicalUsage as Record } : {}), + }; + parentId = entry.id; + result.entries.push(entry); + }; + const base = (timestamp: unknown) => ({ id: "", parentId: null, timestamp: time(timestamp) }); + const content = (value: unknown): (TextContent | ImageContent)[] => { + if (typeof value === "string") return value ? [{ type: "text", text: value }] : []; + const blocks: (TextContent | ImageContent)[] = []; + for (const raw of list(value)) { + const block = record(raw); + if (["text", "input_text", "output_text"].includes(text(block.type))) { + blocks.push({ type: "text", text: text(block.text) }); + } else if (block.type === "image" && record(block.source).type === "base64") { + const image = record(block.source); + const mime = text(image.media_type); + const data = text(image.data); + if (/^image\/(?:png|jpeg|gif|webp)$/u.test(mime) && data.length <= 28_000_000) + blocks.push({ type: "image", data, mimeType: mime }); + else warn("attachment_missing"); + } else if (block.type === "input_image") { + const url = text(block.image_url); + const match = /^data:(image\/(?:png|jpeg|gif|webp));base64,([A-Za-z0-9+/=\r\n]+)$/u.exec(url); + if (match && match[2].length <= 28_000_000) + blocks.push({ type: "image", mimeType: match[1], data: match[2] }); + else warn("attachment_missing"); + } else if (block.type !== "tool_use" && block.type !== "tool_result" && block.type !== "thinking") { + warn("unsupported_content"); + } + } + return blocks; + }; + const assistant = ( + blocks: (TextContent | ImageContent | ThinkingContent | ToolCall)[], + timestamp: unknown, + itemId: string, + usage?: unknown, + ) => { + const supported = blocks.filter((b): b is TextContent | ThinkingContent | ToolCall => b.type !== "image"); + if (!supported.length) return; + append( + { + ...base(timestamp), + type: "message", + message: { + role: "assistant", + content: supported, + api: "imported", + provider: source, + model: "historical", + usage: structuredClone(EMPTY_USAGE), + stopReason: supported.some((b) => b.type === "toolCall") ? "toolUse" : "stop", + timestamp: Date.parse(time(timestamp)), + }, + }, + itemId, + usage, + ); + }; + const user = (blocks: (TextContent | ImageContent)[], timestamp: unknown, itemId: string) => { + const safeBlocks = blocks.flatMap((block): (TextContent | ImageContent)[] => { + if (block.type !== "text") return [block]; + const cleaned = stripImportedUserControls(block.text); + if (cleaned.removed) warn("source_instructions_excluded"); + return cleaned.text ? [{ ...block, text: cleaned.text }] : []; + }); + if (!safeBlocks.length) return; + if (!result.title) + result.title = safeBlocks + .filter((b) => b.type === "text") + .map((b) => b.text) + .join(" ") + .slice(0, 120); + append( + { + ...base(timestamp), + type: "message", + message: { role: "user", content: safeBlocks, timestamp: Date.parse(time(timestamp)) }, + }, + itemId, + ); + }; + const calls = new Map(); + const tool = (id: string, name: string, args: unknown): ToolCall => { + if (!id || !name) throw new Error("Tool call is missing its ID or name"); + let parsed: unknown = args; + if (typeof args === "string") { + try { + parsed = JSON.parse(args); + } catch { + parsed = { raw: args }; + warn("tool_arguments_as_text"); + } + } + calls.set(id, name); + return { type: "toolCall", id, name, arguments: record(parsed) }; + }; + const toolResult = ( + id: string, + output: unknown, + failed: boolean | undefined, + timestamp: unknown, + itemId: string, + ) => { + if (!id) throw new Error("Tool result is missing its call ID"); + append( + { + ...base(timestamp), + type: "message", + message: { + role: "toolResult", + toolCallId: id, + toolName: calls.get(id) ?? "unknown", + content: content(typeof output === "object" && !Array.isArray(output) ? JSON.stringify(output) : output), + isError: failed === true, + details: { importedStatus: failed === undefined ? "unknown" : failed ? "failed" : "completed" }, + timestamp: Date.parse(time(timestamp)), + }, + }, + itemId, + ); + }; + const hasResponseUsers = records.some( + (r) => r.type === "response_item" && record(r.payload).type === "message" && record(r.payload).role === "user", + ); + for (const r of records) { + const fallbackId = `record-${sequence++}`; + const p = record(r.payload); + const id = + text(source === "codex" ? p.id : source === "workbuddy" ? r.id : r.uuid) || + (source === "codex" && r.type === "response_item" && typeof p.call_id === "string" + ? `${text(p.type)}:${p.call_id}` + : fallbackId); + if (source === "codex") { + if (r.type === "event_msg" && p.type === "thread_rolled_back") { + const n = p.num_turns; + if (typeof n !== "number" || !Number.isSafeInteger(n) || n < 0) throw new Error("Unsupported rollback"); + for (let i = 0; i < n; i++) { + let index = result.entries.length - 1; + while (index >= 0) { + const entry = result.entries[index]; + if (entry.type === "message" && entry.message.role === "user") break; + index--; + } + if (index < 0) throw new Error("Incomplete history for rollback"); + result.entries.splice(index); + } + parentId = result.entries.at(-1)?.id ?? null; + warn("rolled_back_history"); + } else if (r.type === "event_msg" && p.type === "user_message" && !hasResponseUsers) { + user(content(p.message), r.timestamp, id); + } else if (r.type === "response_item") { + if (p.type === "message") { + if (p.role === "user") { + user(content(p.content), r.timestamp, id); + } else if (p.role === "assistant") assistant(content(p.content), r.timestamp, id); + else warn("source_instructions_excluded"); + } else if (p.type === "function_call" || p.type === "custom_tool_call") { + assistant([tool(text(p.call_id), text(p.name), p.arguments ?? { input: p.input })], r.timestamp, id); + } else if (p.type === "function_call_output" || p.type === "custom_tool_call_output") { + toolResult(text(p.call_id), p.output, undefined, r.timestamp, id); + } else if (p.type === "reasoning") { + const summary = list(p.summary) + .map((s) => text(record(s).text)) + .filter(Boolean); + if (summary.length) assistant([{ type: "thinking", thinking: summary.join("\n") }], r.timestamp, id); + if (p.encrypted_content) warn("encrypted_reasoning_excluded"); + } else { + warn("unsupported_item"); + assistant( + [{ type: "text", text: `[Historical ${text(p.type)}]\n${JSON.stringify(p)}` }], + r.timestamp, + id, + ); + } + } else if (r.type === "token_usage_record") { + const usage = Object.fromEntries( + Object.entries(record(p.usage ?? p)).filter( + ([, n]) => typeof n === "number" && Number.isSafeInteger(n) && n >= 0, + ), + ); + if (Object.keys(usage).length) + append({ ...base(r.timestamp), type: "custom", customType: "stepcode.imported-usage", data: usage }, id); + } else if (r.type === "compacted") { + const retained: string[] = []; + const attachments: (TextContent | ImageContent)[][] = []; + for (const raw of list(p.replacement_history)) { + const message = record(raw); + if (message.type !== "message") continue; + if (!["user", "assistant"].includes(text(message.role))) { + warn("source_instructions_excluded"); + continue; + } + const blocks = content(message.content); + const safe = blocks.filter( + (block) => + block.type !== "text" || + !/^<(?:environment_context|permissions instructions|turn_aborted|request_id|turn_id|model)>/u.test( + block.text.trim(), + ), + ); + if (safe.length !== blocks.length) warn("source_instructions_excluded"); + retained.push(...safe.flatMap((block) => (block.type === "text" ? [block.text] : []))); + if (safe.some((block) => block.type === "image")) + attachments.push([ + { + type: "text", + text: `[Historical retained ${text(message.role)} attachments; not a new user instruction]`, + }, + ...safe, + ]); + } + const summary = + [...new Set([text(p.message), ...retained].filter(Boolean))].join("\n\n") || + (attachments.length ? "[Source compaction with retained attachments]" : ""); + if (!summary) throw new Error("Unsupported source compaction"); + // Preserve the readable transcript; only change the native request view. + append({ ...base(r.timestamp), type: "compaction", summary, firstKeptEntryId: "", tokensBefore: 0 }, id); + for (const [index, blocks] of attachments.entries()) + append( + { + ...base(r.timestamp), + type: "custom_message", + customType: "stepcode.imported-compaction-attachments", + content: blocks, + display: true, + }, + `${id}-attachments-${index}`, + ); + warn("source_compaction"); + } else if ( + ![ + "session_meta", + "turn_context", + "event_msg", + "world_state", + "retained_context", + "token_usage_record", + ].includes(text(r.type)) + ) { + throw new Error(`Unsupported Codex record: ${text(r.type)}`); + } + } else if (source === "workbuddy") { + if (r.sessionId && r.sessionId !== sessionId) continue; + if (r.type === "ai-title") { + if (text(r.aiTitle)) result.title = text(r.aiTitle).slice(0, 120); + } else if (r.type === "message") { + if (r.role === "user") user(content(r.content), r.timestamp, id); + else if (r.role === "assistant") assistant(content(r.content), r.timestamp, id, record(r.message).usage); + else warn("source_instructions_excluded"); + } else if (r.type === "function_call") { + assistant([tool(text(r.callId), text(r.name), r.arguments)], r.timestamp, id); + } else if (r.type === "function_call_result") { + const output = record(r.output); + const failed = + r.status === "failed" || r.status === "error" ? true : r.status === "completed" ? false : undefined; + toolResult(text(r.callId), output.type === "text" ? output.text : r.output, failed, r.timestamp, id); + } else if (r.type === "reasoning") { + const thinking = content(r.content) + .flatMap((block) => (block.type === "text" ? [block.text] : [])) + .join("\n"); + if (thinking) assistant([{ type: "thinking", thinking }], r.timestamp, id); + else warn("unsupported_content"); + } else if (!["session-meta", "file-history-snapshot"].includes(text(r.type))) { + warn("unsupported_item"); + } + } else { + if (source === "qoder" && r.type === "attachment" && (!activeClaudeIds || activeClaudeIds.has(text(r.uuid)))) + warn("unsupported_content"); + if (r.isCompactSummary === true || (r.type === "system" && r.subtype === "compact_boundary")) + warn("claude_compaction"); + if (r.isSidechain === true) { + warn("child_history_excluded"); + continue; + } + if (r.type === "summary" || r.type === "custom-title") { + const title = text(r.customTitle ?? r.summary); + if (title) result.title = title.slice(0, 120); + } + if (r.type !== "user" && r.type !== "assistant") continue; + if (activeClaudeIds && !activeClaudeIds.has(text(r.uuid))) continue; + const message = record(r.message); + if (r.type === "user") { + user(content(message.content), r.timestamp, id); + for (const [i, raw] of list(message.content).entries()) { + const block = record(raw); + if (block.type === "tool_result") + toolResult( + text(block.tool_use_id), + block.content, + typeof block.is_error === "boolean" ? block.is_error : undefined, + r.timestamp, + `${id}-tool-${i}`, + ); + } + } else { + const blocks: (TextContent | ImageContent | ThinkingContent | ToolCall)[] = []; + for (const raw of list(message.content)) { + const block = record(raw); + if (block.type === "tool_use") blocks.push(tool(text(block.id), text(block.name), block.input)); + else if (block.type === "thinking") { + if (text(block.thinking)) blocks.push({ type: "thinking", thinking: text(block.thinking) }); + } else blocks.push(...content([block])); + } + assistant(blocks, r.timestamp, id, message.usage); + } + } + } + const answered = new Set( + result.entries.flatMap((e) => + e.type === "message" && e.message.role === "toolResult" ? [e.message.toolCallId] : [], + ), + ); + for (const id of calls.keys()) if (!answered.has(id)) warn("tool_result_missing"); + for (const e of result.entries) { + if (e.type === "message" && e.message.role === "toolResult" && !calls.has(e.message.toolCallId)) + warn("tool_call_missing"); + } + result.warnings = [...warnings].map(([code, count]): ImportWarning => ({ code, count })); + if (!result.entries.some((e) => e.type === "message" && e.message.role === "user")) + throw new Error("No supported user conversation found"); + // Rebuild after rollback so no kept entry points to a removed node. + parentId = null; + for (const entry of result.entries) { + entry.parentId = parentId; + parentId = entry.id; + } + return result; +} diff --git a/packages/coding-agent/src/core/session-import/service.ts b/packages/coding-agent/src/core/session-import/service.ts new file mode 100644 index 00000000..9063a51a --- /dev/null +++ b/packages/coding-agent/src/core/session-import/service.ts @@ -0,0 +1,853 @@ +import { createHash, randomUUID } from "node:crypto"; +import { + link, + lstat, + mkdir, + open, + readdir, + readFile, + realpath, + rename, + stat, + unlink, + writeFile, +} from "node:fs/promises"; +import { homedir } from "node:os"; +import { basename, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { FileEntry, SessionFilePersistedEvent } from "../session-manager.ts"; +import { stripImportedUserControls } from "./content.ts"; +import { parseForeignSession, record } from "./parsers.ts"; +import type { + ImportCandidate, + ImportDocument, + ImportJob, + ImportPreview, + ImportResult, + ImportSelection, + ImportSource, + ParsedImport, +} from "./types.ts"; +import { isImportProvenance, isImportSource } from "./types.ts"; + +const MAX_SOURCE_BYTES = 128 * 1024 * 1024; +const HEADER_BYTES = 1024 * 1024; + +function readableTitle(value: unknown): string { + return typeof value === "string" ? value.replace(/\s+/gu, " ").trim().slice(0, 120) : ""; +} + +function userTitle(content: unknown): string { + if (typeof content === "string") return readableTitle(stripImportedUserControls(content).text); + if (!Array.isArray(content)) return ""; + return readableTitle( + content + .filter((block) => ["text", "input_text"].includes(String(record(block).type))) + .map((block) => { + const value = record(block).text; + return typeof value === "string" ? stripImportedUserControls(value).text : ""; + }) + .filter((text) => typeof text === "string") + .join(" "), + ); +} + +function within(root: string, path: string): boolean { + const rel = relative(root, path); + return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel); +} +const MAX_CANDIDATES = 5000; +const MAX_DEPTH = 16; +const MAX_PREVIEW_BYTES = 256 * 1024 * 1024; +const PREVIEW_TTL_MS = 15 * 60 * 1000; + +async function sourceAttachments(input: string, root: string): Promise<{ input: string; digest: string }> { + const fingerprints: string[] = []; + let total = 0; + const localImage = async (value: string): Promise<{ data: string; mime: string } | undefined> => { + if (!value || /^data:|^https?:/u.test(value)) return undefined; + try { + const path = await realpath( + value.startsWith("file:") ? fileURLToPath(value) : isAbsolute(value) ? value : join(root, value), + ); + const rel = relative(root, path); + if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return undefined; + const info = await stat(path); + if (!info.isFile() || info.size > 20 * 1024 * 1024 || total + info.size > 32 * 1024 * 1024) return undefined; + const data = await readFile(path); + const after = await stat(path); + if (info.size !== after.size || info.mtimeMs !== after.mtimeMs) + throw new SessionImportError("Attachment changed; preview again", 409); + const mime = data.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) + ? "image/png" + : data[0] === 255 && data[1] === 216 + ? "image/jpeg" + : data.subarray(0, 3).toString() === "GIF" + ? "image/gif" + : data.subarray(0, 4).toString() === "RIFF" && data.subarray(8, 12).toString() === "WEBP" + ? "image/webp" + : undefined; + if (!mime) return undefined; + total += data.length; + fingerprints.push(`${rel}:${createHash("sha256").update(data).digest("hex")}`); + return { data: data.toString("base64"), mime }; + } catch (error) { + if (error instanceof SessionImportError) throw error; + if (["ENOENT", "EACCES", "EPERM", "EINVAL"].includes(String(record(error).code))) return undefined; + throw error; + } + }; + const visit = async (value: unknown, depth: number): Promise => { + if (depth > 32) throw new SessionImportError("Source content nesting limit exceeded"); + if (Array.isArray(value)) { + for (const child of value) await visit(child, depth + 1); + return; + } + const r = record(value); + if (r.type === "input_image" && typeof r.image_url === "string" && !r.image_url.startsWith("data:")) { + const image = await localImage(r.image_url); + if (image) r.image_url = `data:${image.mime};base64,${image.data}`; + } + if (r.type === "image" && record(r.source).type === "file" && typeof record(r.source).path === "string") { + const image = await localImage(String(record(r.source).path)); + if (image) r.source = { type: "base64", media_type: image.mime, data: image.data }; + } + for (const child of Object.values(r)) if (child && typeof child === "object") await visit(child, depth + 1); + }; + const lines: string[] = []; + for (const line of input.split(/\r?\n/u)) { + if (!line.trim()) continue; + let value: unknown; + try { + value = JSON.parse(line); + } catch { + throw new SessionImportError("Invalid JSONL snapshot; wait for the source to finish writing"); + } + await visit(value, 0); + lines.push(JSON.stringify(value)); + } + return { input: lines.join("\n"), digest: fingerprints.join("\n") }; +} + +interface CandidateFile { + candidate: ImportCandidate; + path: string; + root: string; +} + +interface ReviewedSnapshot { + data: string; + bytes: number; + expires: number; + users: number; +} + +export interface ImportScanOptions { + customRoot?: string; + workbuddyRoot?: string; + qoderRoot?: string; + codexRoot?: string; + claudeRoot?: string; + includeArchived?: boolean; +} + +export interface ImportScanResult { + existingCount: number; + roots: Partial>; + candidates: ImportCandidate[]; + errors: { source: ImportSource; message: string }[]; +} + +export class SessionImportError extends Error { + readonly status: number; + constructor(message: string, status = 400) { + super(message); + this.status = status; + } +} + +/** A local, offline migration service. Source files are opened read-only, never as live sessions. */ +export class SessionImportService { + private readonly agentDir: string; + private readonly sessionRoot: string; + private readonly onPersisted?: (event: SessionFilePersistedEvent) => void; + private readonly candidates = new Map(); + private readonly explicitTitles = new WeakSet(); + private readonly jobs = new Map(); + private readonly running = new Map>(); + private readonly cancelled = new Set(); + private readonly previews = new Map(); + private queue: Promise = Promise.resolve(); + private closed = false; + + constructor(options: { + agentDir: string; + sessionRoot?: string; + onPersisted?: (event: SessionFilePersistedEvent) => void; + }) { + this.agentDir = resolve(options.agentDir); + this.sessionRoot = resolve(options.sessionRoot ?? join(this.agentDir, "sessions")); + this.onPersisted = options.onPersisted; + } + + async scan(options: ImportScanOptions = {}): Promise { + if (this.closed) throw new SessionImportError("Importer is closed", 503); + const result: ImportScanResult = { existingCount: 0, roots: {}, candidates: [], errors: [] }; + const roots: [ImportSource, string][] = [ + [ + "workbuddy", + options.customRoot?.trim() || options.workbuddyRoot?.trim() || join(homedir(), ".workbuddy", "projects"), + ], + ["qoder", options.customRoot?.trim() || options.qoderRoot?.trim() || join(homedir(), ".qoder", "projects")], + [ + "codex", + options.customRoot?.trim() || + options.codexRoot?.trim() || + process.env.CODEX_HOME?.trim() || + join(homedir(), ".codex"), + ], + [ + "claude-code", + options.customRoot?.trim() || + options.claudeRoot?.trim() || + join(process.env.CLAUDE_CONFIG_DIR?.trim() || join(homedir(), ".claude"), "projects"), + ], + ]; + // Custom scans append capabilities; automatic scans replace them after discovery settles. + const candidates = new Map(options.customRoot ? this.candidates : []); + const knownPaths = new Set([...candidates.values()].map((file) => `${file.candidate.source}\0${file.path}`)); + for (const [source, input] of roots) { + try { + const root = await realpath(input); + if (!options.customRoot) result.roots[source] = root; + if (root.split(sep).includes("archived_sessions") && !options.includeArchived) continue; + const rootEntries = await readdir(root, { withFileTypes: true }); + const codexHome = + source === "codex" && + rootEntries.some( + (entry) => + (entry.isDirectory() && ["sessions", "archived_sessions"].includes(entry.name)) || + entry.name === "session_index.jsonl", + ); + const titles = new Map(); + if (source === "codex") { + const index = join(root, "session_index.jsonl"); + try { + const info = await stat(index); + if (info.size > 32 * 1024 * 1024) throw new Error("Source title index exceeds 32 MiB"); + for (const line of (await readFile(index, "utf8")).split(/\r?\n/u)) { + if (!line.trim()) continue; + const entry = record(JSON.parse(line)); + if (typeof entry.id === "string" && typeof entry.thread_name === "string") + titles.set(entry.id, entry.thread_name.slice(0, 120)); + } + } catch (error) { + if (record(error).code !== "ENOENT") + result.errors.push({ + source, + message: `Title index: ${error instanceof Error ? error.message : String(error)}`, + }); + } + } + const walk = async (dir: string, depth: number): Promise => { + if (depth > MAX_DEPTH) throw new Error("Source directory depth limit exceeded"); + const files = await readdir(dir, { withFileTypes: true }); + for (const file of files) { + if (this.closed) throw new Error("Importer is closed"); + if (file.isSymbolicLink()) continue; + const archived = file.name === "archived_sessions"; + if (archived && !options.includeArchived) continue; + if ( + ["node_modules", ".git", "attachments", "worktrees", "backups", "backups_state"].includes( + file.name, + ) + ) + continue; + const path = join(dir, file.name); + if (file.isDirectory()) { + if ( + source === "codex" && + codexHome && + dir === root && + !["sessions", "archived_sessions"].includes(file.name) + ) + continue; + await walk(path, depth + 1); + } else if ( + file.isFile() && + file.name.endsWith(".jsonl") && + (!!options.customRoot || source !== "codex" || file.name.startsWith("rollout-")) + ) { + const key = `${source}\0${path}`; + if (knownPaths.has(key)) { + result.existingCount++; + result.roots[source] = root; + continue; + } + if (candidates.size >= MAX_CANDIDATES) + throw new Error("Session limit exceeded; select a narrower source directory"); + try { + const candidate = await this.discover(source, path, root, !!options.customRoot); + if (!candidate || (candidate.archived && !options.includeArchived)) continue; + const indexedTitle = readableTitle(titles.get(candidate.sessionId)); + if (indexedTitle) { + candidate.title = indexedTitle; + this.explicitTitles.add(candidate); + } + candidates.set(candidate.id, { candidate, path, root }); + knownPaths.add(key); + result.candidates.push(candidate); + result.roots[source] = root; + } catch (error) { + result.errors.push({ + source, + message: `${file.name}: ${error instanceof Error ? error.message : String(error)}`, + }); + } + } + } + }; + await walk(root, 0); + } catch (error) { + // An absent optional client is normal; permission and parse errors must remain visible. + if (options.customRoot || record(error).code !== "ENOENT") + result.errors.push({ source, message: error instanceof Error ? error.message : String(error) }); + } + } + this.candidates.clear(); + for (const [id, candidate] of candidates) this.candidates.set(id, candidate); + result.candidates.sort((a, b) => b.modified.localeCompare(a.modified)); + return result; + } + + private async discover( + source: ImportSource, + path: string, + root: string, + allowOtherFormat = false, + ): Promise { + const handle = await open(path, "r"); + try { + const info = await handle.stat(); + if (!info.isFile() || info.size > MAX_SOURCE_BYTES) + throw new Error("Source is not a regular file or exceeds 128 MiB"); + let metadata: Record | undefined; + let detected: ImportSource | undefined; + let projectCwd = ""; + let title = ""; + let firstUser = ""; + let pending: Buffer[] = []; + const inspect = (bytes: Buffer): void => { + const line = bytes.toString("utf8"); + if (!line.trim()) return; + let value: unknown; + try { + value = JSON.parse(line); + } catch (error) { + // Only preview validates the transcript after discovery has established its identity. + if (!metadata) throw error; + return; + } + const r = record(value); + if (r.type === "workspace-directories" || r.type === "runtime-config") detected = "qoder"; + if (r.type === "session-meta" || r.type === "ai-title") detected = "workbuddy"; + if (typeof r.cwd === "string" && !projectCwd) projectCwd = r.cwd; + if (source === "codex") { + const p = record(r.payload); + if (r.type === "session_meta") { + metadata ??= p; + title = readableTitle(p.title) || readableTitle(p.thread_name) || title; + } + if (!firstUser && r.type === "response_item" && p.type === "message" && p.role === "user") + firstUser = userTitle(p.content); + if (!firstUser && r.type === "event_msg" && p.type === "user_message") firstUser = userTitle(p.message); + } else if (source === "workbuddy") { + if (r.type === "session-meta" && typeof r.sessionId === "string") metadata ??= r; + if (r.type === "ai-title") title = readableTitle(r.aiTitle) || title; + if (r.type === "message" && r.role === "user" && !firstUser) firstUser = userTitle(r.content); + } else { + if ((r.type === "user" || r.type === "assistant") && typeof r.sessionId === "string") metadata ??= r; + if (r.isSidechain === true || (metadata && r.sessionId && r.sessionId !== metadata.sessionId)) return; + if (r.type === "custom-title" || r.type === "summary") + title = readableTitle(r.customTitle) || readableTitle(r.summary) || title; + else title = readableTitle(r.customTitle) || title; + if (!firstUser && r.type === "user") firstUser = userTitle(record(r.message).content); + } + }; + // Title reads stop at 1 MiB. Metadata may share a larger record with message/image content; + // retain complete-record discovery up to the source cap until identity is established. + let position = 0; + while (position < info.size && (!metadata || position < HEADER_BYTES)) { + const limit = metadata ? Math.min(info.size, HEADER_BYTES) : info.size; + const buffer = Buffer.alloc(Math.min(limit - position, 64 * 1024)); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, position); + if (!bytesRead) break; + position += bytesRead; + const chunk = buffer.subarray(0, bytesRead); + let start = 0; + for (let end = chunk.indexOf(10); end !== -1; end = chunk.indexOf(10, start)) { + pending.push(chunk.subarray(start, end)); + inspect(Buffer.concat(pending)); + pending = []; + start = end + 1; + if (metadata && position - chunk.length + start >= HEADER_BYTES) { + start = chunk.length; + break; + } + } + if (start < chunk.length) pending.push(chunk.subarray(start)); + } + if (position === info.size && pending.length) inspect(Buffer.concat(pending)); + if ( + (detected && detected !== source) || + (source === "qoder" && detected !== "qoder") || + (source === "workbuddy" && detected !== "workbuddy") + ) + return undefined; + if (!metadata) { + if (allowOtherFormat) return undefined; + throw new Error("Session metadata is missing from the bounded header"); + } + if ( + metadata.isSidechain === true || + metadata.parentAgentId || + record(metadata.source).subagent || + record(metadata.thread_source).subagent + ) + return undefined; + const sessionId = source === "codex" ? metadata.id : metadata.sessionId; + if (typeof sessionId !== "string" || !sessionId) throw new Error("Invalid source session ID"); + const cwd = typeof metadata.cwd === "string" ? metadata.cwd : projectCwd; + const archived = + relative(root, path).split(sep).includes("archived_sessions") || + root.split(sep).includes("archived_sessions") || + metadata.archived === true; + const candidate: ImportCandidate = { + id: randomUUID(), + sourcePath: path, + source, + sessionId, + title: title || firstUser || `${basename(cwd) || source} · ${sessionId.slice(0, 8)}`, + cwd, + modified: info.mtime.toISOString(), + archived, + version: + typeof metadata.cli_version === "string" + ? metadata.cli_version + : typeof metadata.version === "string" + ? metadata.version + : "unknown", + }; + if (title) this.explicitTitles.add(candidate); + return candidate; + } finally { + await handle.close(); + } + } + + private async snapshot( + id: string, + file = this.candidates.get(id), + ): Promise<{ parsed: ParsedImport; preview: ImportPreview }> { + if (!file) throw new SessionImportError("Candidate expired; scan again", 409); + const path = await realpath(file.path); + const rel = relative(file.root, path); + if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel) || (await lstat(file.path)).isSymbolicLink()) + throw new SessionImportError("Source escaped its selected directory"); + const handle = await open(path, "r"); + let input: string; + try { + const before = await handle.stat(); + if (!before.isFile() || before.size > MAX_SOURCE_BYTES) throw new SessionImportError("Source exceeds 128 MiB"); + input = await handle.readFile("utf8"); + const after = await handle.stat(); + const current = await stat(path); + if ( + before.size !== after.size || + before.mtimeMs !== after.mtimeMs || + before.ino !== current.ino || + current.size !== after.size || + current.mtimeMs !== after.mtimeMs + ) + throw new SessionImportError("Source changed while reading; preview again", 409); + } finally { + await handle.close(); + } + const attachments = await sourceAttachments(input, file.root); + const parsed = parseForeignSession(file.candidate.source, attachments.input); + if (parsed.child || parsed.sessionId !== file.candidate.sessionId) + throw new SessionImportError("Source identity changed", 409); + if (parsed.archived && !file.candidate.archived) + throw new SessionImportError("Source was archived; scan again", 409); + const messages = parsed.entries.filter((e) => e.type === "message"); + return { + parsed, + preview: { + ...file.candidate, + title: this.explicitTitles.has(file.candidate) + ? file.candidate.title + : parsed.title || file.candidate.title, + fingerprint: createHash("sha256") + .update(input) + .update(attachments.digest ? `\0${attachments.digest}` : "") + .digest("hex"), + messageCount: messages.length, + toolCount: messages.reduce( + (n, e) => + n + + (e.message.role === "assistant" ? e.message.content.filter((b) => b.type === "toolCall").length : 0), + 0, + ), + warnings: parsed.warnings, + excerpt: messages + .flatMap((e) => { + const m = e.message; + if (!("content" in m)) return []; + const body = + typeof m.content === "string" + ? m.content + : m.content + .filter((b) => b.type === "text") + .map((b) => b.text) + .join("\n"); + return body ? [`${m.role}: ${body}`] : []; + }) + .join("\n\n") + .slice(0, 12000), + }, + }; + } + + async preview(id: string): Promise { + if (this.closed) throw new SessionImportError("Importer is closed", 503); + const { parsed, preview } = await this.snapshot(id); + if (this.closed) throw new SessionImportError("Importer is closed", 503); + const key = `${id}:${preview.fingerprint}`; + const existing = this.previews.get(key); + if (existing) { + existing.expires = Date.now() + PREVIEW_TTL_MS; + return preview; + } + const document: ImportDocument = { preview, entries: parsed.entries }; + const data = JSON.stringify(document); + const bytes = data.length * 2; + if (bytes > MAX_PREVIEW_BYTES) throw new SessionImportError("Preview snapshot exceeds cache limit", 413); + this.prunePreviews(); + let used = [...this.previews.values()].reduce((sum, entry) => sum + entry.bytes, 0); + for (const [oldKey, entry] of this.previews) { + if (used + bytes <= MAX_PREVIEW_BYTES) break; + if (entry.users) continue; + this.previews.delete(oldKey); + used -= entry.bytes; + } + if (used + bytes > MAX_PREVIEW_BYTES) + throw new SessionImportError("Preview cache is busy; wait for current imports to finish", 409); + this.previews.set(key, { data, bytes, expires: Date.now() + PREVIEW_TTL_MS, users: 0 }); + return preview; + } + + private prunePreviews(): void { + for (const [key, entry] of this.previews) { + if (!entry.users && entry.expires <= Date.now()) this.previews.delete(key); + } + } + + private async saveJob(job: ImportJob): Promise { + const dir = join(this.agentDir, "session-imports"); + await mkdir(dir, { recursive: true }); + const path = join(dir, `${job.id}.json`); + const temporary = `${path}.${randomUUID()}.tmp`; + try { + await writeFile(temporary, JSON.stringify(job), { flag: "wx", mode: 0o600 }); + await rename(temporary, path); + } finally { + await unlink(temporary).catch((error: unknown) => { + // A successful rename has already removed the temporary directory entry. + if (record(error).code !== "ENOENT") throw error; + }); + } + } + + async listJobs(): Promise { + const dir = join(this.agentDir, "session-imports"); + let names: string[]; + try { + names = await readdir(dir); + } catch (error) { + if (record(error).code === "ENOENT") return []; + throw error; + } + const jobs: ImportJob[] = []; + for (const name of names.filter((n) => /^[a-f0-9-]{36}\.json$/u.test(n))) { + const id = name.slice(0, -5); + const job = await this.getJob(id); + jobs.push(job); + } + return jobs; + } + + async getJob(id: string): Promise { + if (!/^[a-f0-9-]{36}$/u.test(id)) throw new SessionImportError("Invalid job ID"); + const active = this.jobs.get(id); + if (active) return structuredClone(active); + let value: unknown; + try { + value = JSON.parse(await readFile(join(this.agentDir, "session-imports", `${id}.json`), "utf8")); + } catch (error) { + if (record(error).code === "ENOENT") throw new SessionImportError("Job not found", 404); + throw error; + } + const r = record(value); + if ( + r.id !== id || + !["running", "completed", "cancelled", "interrupted"].includes(String(r.status)) || + !Number.isSafeInteger(r.total) || + Number(r.total) < 1 || + Number(r.total) > 100 || + !Array.isArray(r.results) + ) + throw new SessionImportError("Invalid persisted import job", 500); + // Only this service owns live jobs; a previous host's unfinished batch cannot still be running. + for (const value of r.results) { + const result = record(value); + if ( + typeof result.candidateId !== "string" || + !["imported", "skipped", "failed"].includes(String(result.status)) || + (result.source !== undefined && !isImportSource(result.source)) || + (result.sourcePath !== undefined && typeof result.sourcePath !== "string") || + (result.sessionId !== undefined && typeof result.sessionId !== "string") || + (result.path !== undefined && typeof result.path !== "string") || + (result.error !== undefined && typeof result.error !== "string") || + (result.warning !== undefined && typeof result.warning !== "string") || + (result.cwd !== undefined && + (typeof result.cwd !== "string" || !isAbsolute(result.cwd) || result.cwd.includes("\0"))) || + (result.title !== undefined && typeof result.title !== "string") + ) + throw new SessionImportError("Invalid persisted import result", 500); + } + if (typeof r.createdAt !== "string" || !Number.isFinite(Date.parse(r.createdAt))) + throw new SessionImportError("Invalid persisted import date", 500); + // The envelope and every result were validated at this persistence boundary. + const job = value as ImportJob; + for (const result of job.results) { + if (!result.path || (result.source && result.cwd && result.title)) continue; + try { + const root = await realpath(join(this.sessionRoot, "imported")); + const path = await realpath(result.path); + if (!within(root, path)) continue; + const handle = await open(path, "r"); + try { + const info = await handle.stat(); + if (!info.isFile()) continue; + const buffer = Buffer.alloc(Math.min(info.size, HEADER_BYTES)); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + const bytes = buffer.subarray(0, bytesRead); + // Discard an incomplete final record, except at the actual file end. + const complete = bytesRead === info.size ? bytes : bytes.subarray(0, bytes.lastIndexOf(10) + 1); + let firstUser = ""; + let name = ""; + for (const line of complete.toString("utf8").split(/\r?\n/u)) { + let value: unknown; + try { + value = JSON.parse(line); + } catch { + continue; + } + const entry = record(value); + if ( + entry.type === "session" && + typeof entry.cwd === "string" && + isAbsolute(entry.cwd) && + !entry.cwd.includes("\0") + ) + result.cwd ??= entry.cwd; + if (entry.type === "session_info") name = readableTitle(entry.name) || name; + const message = record(entry.message); + if (!firstUser && entry.type === "message" && message.role === "user") + firstUser = userTitle(message.content); + if (!result.source && isImportProvenance(entry.imported)) result.source = entry.imported.source; + } + if (!result.title) { + // Native imported names have a display prefix; journal titles do not. + const title = name.replace(/^\[(codex|claude-code)\] /u, "") || firstUser; + if (title) result.title = title; + } + } finally { + await handle.close(); + } + } catch { + // Deleted or inaccessible native sessions leave truthful unknown metadata in old journals. + } + } + if (job.status === "running") { + job.status = "interrupted"; + await this.saveJob(job); + } + return job; + } + + async start(selections: ImportSelection[]): Promise { + if (this.closed) throw new SessionImportError("Importer is closed", 503); + if (!selections.length || selections.length > 100) + throw new SessionImportError("Select between 1 and 100 sessions"); + this.prunePreviews(); + const submitted = selections.map((selection) => { + const file = this.candidates.get(selection.candidateId); + if (!file || !/^[a-f0-9]{64}$/u.test(selection.fingerprint) || !isAbsolute(selection.cwd)) + throw new SessionImportError("Invalid selection; preview and choose an absolute project directory"); + const snapshot = this.previews.get(`${selection.candidateId}:${selection.fingerprint}`); + if (!snapshot || snapshot.expires <= Date.now()) + throw new SessionImportError("Preview expired; preview the selected session again", 409); + return { selection: { ...selection }, file, snapshot }; + }); + const job: ImportJob = { + id: randomUUID(), + createdAt: new Date().toISOString(), + status: "running", + total: selections.length, + results: [], + }; + // Pin before the first await so scans, eviction and other jobs cannot replace reviewed content. + for (const { snapshot } of submitted) snapshot.users++; + try { + await this.saveJob(job); + } catch (error) { + for (const { snapshot } of submitted) snapshot.users--; + throw error; + } + this.jobs.set(job.id, job); + const run = this.queue + .then(async () => { + for (const { selection, file, snapshot } of submitted) { + if (this.cancelled.has(job.id) || this.closed) break; + let result: ImportResult; + try { + result = await this.commit(selection, snapshot); + } catch (error) { + result = { + candidateId: selection.candidateId, + source: file.candidate.source, + sourcePath: file.path, + title: file.candidate.title, + cwd: selection.cwd, + status: "failed", + error: error instanceof Error ? error.message : String(error), + }; + } + job.results.push(result); + await this.saveJob(job); + } + job.status = this.cancelled.has(job.id) || this.closed ? "cancelled" : "completed"; + await this.saveJob(job); + }) + .catch(async (error: unknown) => { + job.status = "interrupted"; + job.results.push({ + candidateId: "", + status: "failed", + error: `Import journal failed: ${error instanceof Error ? error.message : String(error)}`, + }); + // Keep the in-memory terminal failure available even if the disk journal is unavailable. + }) + .finally(() => { + for (const { snapshot } of submitted) snapshot.users--; + this.prunePreviews(); + this.running.delete(job.id); + this.cancelled.delete(job.id); + }); + this.queue = run; + this.running.set(job.id, run); + return structuredClone(job); + } + + async cancel(id: string): Promise { + const job = await this.getJob(id); + if (job.status === "running") this.cancelled.add(id); + return job; + } + + async close(): Promise { + this.closed = true; + await this.queue; + this.previews.clear(); + } + + private async commit(selection: ImportSelection, snapshot: ReviewedSnapshot): Promise { + // This immutable document was serialized by preview(), not supplied by the client or source. + const { entries: reviewedEntries, preview } = JSON.parse(snapshot.data) as ImportDocument; + const cwd = await realpath(selection.cwd); + if (!(await stat(cwd)).isDirectory()) throw new SessionImportError("Project directory does not exist"); + const hash = createHash("sha256") + .update(`session-import-v2\0${preview.source}\0${preview.sessionId}\0${preview.fingerprint}`) + .digest("hex"); + const id = `${hash.slice(0, 8)}-${hash.slice(8, 12)}-4${hash.slice(13, 16)}-a${hash.slice(17, 20)}-${hash.slice(20, 32)}`; + const dir = join(this.sessionRoot, "imported"); + await mkdir(dir, { recursive: true }); + const path = join(dir, `${id}.jsonl`); + const temporary = join(dir, `.${id}.${randomUUID()}.tmp`); + const timestamp = new Date().toISOString(); + const last = reviewedEntries.at(-1); + const leaf = last && "id" in last ? last.id : null; + const entries: FileEntry[] = [ + { type: "session", version: 3, id, timestamp, cwd }, + ...reviewedEntries, + { + type: "custom", + id: randomUUID().slice(0, 8), + parentId: leaf, + timestamp, + customType: "stepcode.session-import", + data: { + converterVersion: 2, + source: preview.source, + sourceSessionId: preview.sessionId, + fingerprint: preview.fingerprint, + version: preview.version, + sourceCwd: preview.cwd, + warnings: preview.warnings, + }, + }, + ]; + const metadataId = entries.at(-1); + entries.push({ + type: "session_info", + id: randomUUID().slice(0, 8), + parentId: metadataId && "parentId" in metadataId ? metadataId.id : leaf, + timestamp, + name: `[${preview.source}] ${preview.title}`, + }); + const handle = await open(temporary, "wx", 0o600); + let status: "imported" | "skipped" = "imported"; + try { + await handle.writeFile(`${entries.map((e) => JSON.stringify(e)).join("\n")}\n`); + await handle.sync(); + await handle.close(); + // A hard-link publication is atomic and, unlike rename, never overwrites another importer. + await link(temporary, path); + } catch (error) { + if (record(error).code === "EEXIST") status = "skipped"; + else throw error; + } finally { + await handle.close(); + await unlink(temporary); + } + let warning: string | undefined; + try { + this.onPersisted?.({ path, kind: "append" }); + } catch (error) { + // Publication is already durable. An index failure must not misreport it as a failed import. + warning = `Session saved; search index notification failed: ${error instanceof Error ? error.message : String(error)}`; + } + return { + candidateId: selection.candidateId, + source: preview.source, + sourcePath: preview.sourcePath, + title: preview.title, + cwd: selection.cwd, + status, + sessionId: id, + path, + warning, + }; + } +} diff --git a/packages/coding-agent/src/core/session-import/types.ts b/packages/coding-agent/src/core/session-import/types.ts new file mode 100644 index 00000000..e94985e8 --- /dev/null +++ b/packages/coding-agent/src/core/session-import/types.ts @@ -0,0 +1,90 @@ +import type { FileEntry, SessionEntry } from "../session-manager.ts"; + +export type ImportSource = "codex" | "claude-code" | "workbuddy" | "qoder"; + +export interface ImportProvenance { + source: ImportSource; + sessionId: string; + itemId: string; + historicalUsage?: Record; +} + +export interface ImportCandidate { + id: string; + source: ImportSource; + sessionId: string; + title: string; + cwd: string; + modified: string; + archived: boolean; + version: string; + /** Absolute source JSONL path discovered by the offline scanner. */ + sourcePath?: string; +} + +export interface ImportWarning { + code: string; + count: number; +} + +export interface ImportPreview extends ImportCandidate { + fingerprint: string; + messageCount: number; + toolCount: number; + warnings: ImportWarning[]; + excerpt: string; +} + +export interface ParsedImport { + sessionId: string; + cwd: string; + title: string; + version: string; + archived: boolean; + child: boolean; + entries: SessionEntry[]; + warnings: ImportWarning[]; +} + +export interface ImportSelection { + candidateId: string; + fingerprint: string; + cwd: string; +} + +export interface ImportResult { + candidateId: string; + source?: ImportSource; + sourcePath?: string; + title?: string; + /** Selected absolute destination project directory, not the source cwd. */ + cwd?: string; + status: "imported" | "skipped" | "failed"; + sessionId?: string; + path?: string; + error?: string; + warning?: string; +} + +export interface ImportJob { + id: string; + createdAt: string; + status: "running" | "completed" | "cancelled" | "interrupted"; + total: number; + results: ImportResult[]; +} + +export interface ImportDocument { + preview: ImportPreview; + entries: FileEntry[]; +} + +export function isImportSource(value: unknown): value is ImportSource { + return value === "codex" || value === "claude-code" || value === "workbuddy" || value === "qoder"; +} + +export function isImportProvenance(value: unknown): value is ImportProvenance { + if (!value || typeof value !== "object") return false; + const record = value as Record; + return isImportSource(record.source) && typeof record.sessionId === "string" && typeof record.itemId === "string"; +} diff --git a/packages/coding-agent/src/core/session-manager.ts b/packages/coding-agent/src/core/session-manager.ts index 6359cada..2f331ec0 100644 --- a/packages/coding-agent/src/core/session-manager.ts +++ b/packages/coding-agent/src/core/session-manager.ts @@ -27,6 +27,8 @@ import { createCompactionSummaryMessage, createCustomMessage, } from "./messages.ts"; +import { stripImportedUserControls } from "./session-import/content.ts"; +import { type ImportProvenance, isImportProvenance } from "./session-import/types.ts"; import { createSessionSummary, foldSessionSummaryEntry, resolveSessionModifiedTime } from "./session-summary.ts"; import { createUsageLineage } from "./usage-lineage.ts"; @@ -58,6 +60,8 @@ export interface SessionEntryBase { id: string; parentId: string | null; timestamp: string; + /** Historical provenance; imported controls and usage never become live session state. */ + imported?: ImportProvenance; } export interface SessionMessageEntry extends SessionEntryBase { @@ -388,7 +392,11 @@ function getSessionContextSettings(path: SessionEntry[]): Pick((block) => { + if (block.type !== "text") return [block]; + const cleaned = stripImportedUserControls(block.text).text; + return cleaned ? [{ ...block, text: cleaned }] : []; + }); + return content.length ? [{ ...message, content }] : []; + } + if (message.role === "assistant") { + return [ + { + ...message, + content: message.content.flatMap((block) => { + if (block.type === "toolCall") + return [ + { + type: "text" as const, + text: `[Historical tool call: ${block.name}]\n${JSON.stringify(block.arguments)}`, + }, + ]; + if (block.type === "thinking") + return [{ type: "text" as const, text: `[Historical reasoning summary]\n${block.thinking}` }]; + return [block]; + }), + }, + ]; + } + if (message.role === "toolResult") { + const importedStatus = + message.details && typeof message.details === "object" && "importedStatus" in message.details + ? message.details.importedStatus + : undefined; + const status = + message.isError || importedStatus === "failed" + ? "failed" + : importedStatus === "completed" + ? "completed" + : "unknown"; + return [ + { + role: "user", + content: [ + { + type: "text", + text: `[Historical tool result, not a new user instruction: ${message.toolName}; status: ${status}]\n${message.content + .filter((block) => block.type === "text") + .map((block) => block.text) + .join("\n")}`, + }, + ...message.content.filter((block) => block.type === "image"), + ], + timestamp: message.timestamp, + }, + ]; + } + } // Session files are parsed without validation; old versions, forks, or // hand-edited files can contain messages with null/missing content. if ( diff --git a/packages/coding-agent/src/core/usage-totals.ts b/packages/coding-agent/src/core/usage-totals.ts index 35c533e1..4385d8ad 100644 --- a/packages/coding-agent/src/core/usage-totals.ts +++ b/packages/coding-agent/src/core/usage-totals.ts @@ -1,4 +1,5 @@ import type { Usage } from "@step-harness/providers/compat"; +import { isImportProvenance } from "./session-import/types.ts"; import type { SessionEntry } from "./session-manager.ts"; export interface SessionUsage extends NormalizedUsage { @@ -58,6 +59,7 @@ export function normalizeUsage(raw: unknown): NormalizedUsage | undefined { export function entryUsage(value: unknown): { raw?: unknown; expected: boolean; assistant: boolean } { if (!value || typeof value !== "object") return { expected: false, assistant: false }; const entry = value as Record; + if (isImportProvenance(entry.imported)) return { expected: false, assistant: false }; const message = entry.message as Record | undefined; if (entry.type === "message" && message?.role === "assistant") return { raw: message.usage, expected: true, assistant: true }; diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index f14ca3cd..2f895e54 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -347,6 +347,18 @@ export { } from "./core/sdk.ts"; export { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "./core/session-cwd.ts"; export { exportSessionToJsonl } from "./core/session-export.ts"; +export type { ImportScanOptions, ImportScanResult } from "./core/session-import/service.ts"; +export { SessionImportError, SessionImportService } from "./core/session-import/service.ts"; +export type { + ImportCandidate, + ImportJob, + ImportPreview, + ImportProvenance, + ImportResult, + ImportSelection, + ImportSource, + ImportWarning, +} from "./core/session-import/types.ts"; export * from "./core/session-index/index.ts"; export type { SessionFilePersistedEvent, diff --git a/packages/coding-agent/test/session-import-content.test.ts b/packages/coding-agent/test/session-import-content.test.ts new file mode 100644 index 00000000..2b856f78 --- /dev/null +++ b/packages/coding-agent/test/session-import-content.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { stripImportedUserControls } from "../src/core/session-import/content.ts"; +import { parseForeignSession } from "../src/core/session-import/parsers.ts"; +import { sessionEntryToContextMessages } from "../src/core/session-manager.ts"; + +const wrapped = "你好"; +const jsonl = (rows: unknown[]) => rows.map((row) => JSON.stringify(row)).join("\n"); + +describe("imported user-query content", () => { + it("unwraps genuine content after source controls without discarding the request", () => { + expect(stripImportedUserControls(wrapped)).toEqual({ text: "你好", removed: false }); + expect(stripImportedUserControls(`control${wrapped}`)).toEqual({ + text: "你好", + removed: true, + }); + }); + + it("preserves user examples, nested literal markup, and incomplete wrappers", () => { + for (const value of [ + "```xml\n你好\n```", + "example: 你好", + "unfinished", + ]) + expect(stripImportedUserControls(value).text).toBe(value); + expect(stripImportedUserControls("outer inner").text).toBe( + "outer inner", + ); + }); + + it.each(["codex", "claude-code", "workbuddy", "qoder"] as const)( + "cleans %s parsing and old imported continuation context", + (source) => { + const records = + source === "codex" + ? [ + { type: "session_meta", payload: { id: "s", cwd: "/project" } }, + { + type: "response_item", + payload: { type: "message", role: "user", content: [{ type: "input_text", text: wrapped }] }, + }, + ] + : source === "workbuddy" + ? [ + { type: "session-meta", sessionId: "s" }, + { + type: "message", + id: "u", + sessionId: "s", + cwd: "/project", + role: "user", + content: [{ type: "input_text", text: wrapped }], + }, + ] + : [ + { + type: "user", + uuid: "u", + sessionId: "s", + cwd: "/project", + message: { role: "user", content: wrapped }, + }, + ]; + const parsed = parseForeignSession(source, jsonl(records)); + expect(parsed.title).toBe("你好"); + const user = parsed.entries.find((entry) => entry.type === "message" && entry.message.role === "user"); + if (!user || user.type !== "message" || user.message.role !== "user") throw new Error("User entry missing"); + expect(user.message.content).toEqual([{ type: "text", text: "你好" }]); + const legacy = { ...user, message: { ...user.message, content: wrapped } }; + const original = JSON.stringify(legacy); + expect(sessionEntryToContextMessages(legacy)[0]).toMatchObject({ role: "user", content: "你好" }); + expect(JSON.stringify(legacy)).toBe(original); + }, + ); +}); diff --git a/packages/coding-agent/test/session-import.test.ts b/packages/coding-agent/test/session-import.test.ts new file mode 100644 index 00000000..ea7a56f0 --- /dev/null +++ b/packages/coding-agent/test/session-import.test.ts @@ -0,0 +1,1213 @@ +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readdir, readFile, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fauxAssistantMessage, fauxToolCall } from "@step-harness/providers"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { parseForeignSession } from "../src/core/session-import/parsers.ts"; +import { SessionImportService } from "../src/core/session-import/service.ts"; +import { buildSessionContext, SessionManager } from "../src/core/session-manager.ts"; +import { createWriteTool } from "../src/core/tools/write.ts"; +import { collectSessionUsage } from "../src/core/usage-totals.ts"; +import { createHarness } from "./suite/harness.ts"; + +const timestamp = "2026-10-22T00:00:00.000Z"; +const jsonl = (records: unknown[]) => `${records.map((r) => JSON.stringify(r)).join("\n")}\n`; +const meta = (cwd = "/project", id = "source-1") => ({ + type: "session_meta", + timestamp, + payload: { id, cwd, cli_version: "0.163.0-alpha.11", history_mode: "paginated" }, +}); +const item = (payload: unknown) => ({ type: "response_item", timestamp, payload }); +const user = (value: string, id?: string) => + item({ type: "message", role: "user", id, content: [{ type: "input_text", text: value }] }); +const reply = (value: string) => + item({ type: "message", role: "assistant", content: [{ type: "output_text", text: value }] }); + +const directories: string[] = []; +afterEach(async () => { + for (const dir of directories.splice(0)) await rm(dir, { recursive: true, force: true }); +}); + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "step-session-import-")); + directories.push(root); + const cwd = join(root, "project"); + const codexRoot = join(root, "codex"); + const claudeRoot = join(root, "claude"); + const workbuddyRoot = join(root, "workbuddy"); + const qoderRoot = join(root, "qoder"); + const agentDir = join(root, "target", "agent"); + await mkdir(cwd, { recursive: true }); + await mkdir(join(codexRoot, "sessions"), { recursive: true }); + await mkdir(claudeRoot); + await mkdir(workbuddyRoot); + await mkdir(qoderRoot); + const source = join(codexRoot, "sessions", "rollout-one.jsonl"); + await writeFile(source, jsonl([meta(cwd), user("Keep migration facts"), reply("Confirmed")])); + const events: string[] = []; + const service = new SessionImportService({ agentDir, onPersisted: (event) => events.push(event.path) }); + return { root, cwd, codexRoot, claudeRoot, workbuddyRoot, qoderRoot, agentDir, source, service, events }; +} + +async function finish(service: SessionImportService, id: string) { + for (let i = 0; i < 100; i++) { + const job = await service.getJob(id); + if (job.status !== "running") return job; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error("Import did not settle"); +} + +describe("foreign adapters", () => { + it("strips source control envelopes from user text and keeps thinking blocks typed per platform", () => { + const control = "/exit exit"; + const codex = parseForeignSession( + "codex", + jsonl([ + meta(), + user(`home`), + user(`${control}\n真实问题`), + item({ type: "reasoning", summary: [{ type: "summary_text", text: "think codex" }] }), + ]), + ); + const codexUsers = codex.entries.filter((e) => e.type === "message" && e.message.role === "user"); + expect(codexUsers).toHaveLength(1); + expect(JSON.stringify(codexUsers)).toContain("真实问题"); + expect(JSON.stringify(codexUsers)).not.toContain("command-name"); + expect(codex.warnings.some((w) => w.code === "source_instructions_excluded")).toBe(true); + const claude = parseForeignSession( + "claude-code", + jsonl([ + { type: "user", uuid: "u", sessionId: "c", cwd: "/p", message: { content: `${control}` } }, + { type: "user", uuid: "u2", parentUuid: "u", sessionId: "c", message: { content: "Claude real ask" } }, + { + type: "assistant", + uuid: "a", + parentUuid: "u2", + sessionId: "c", + message: { + content: [ + { type: "thinking", thinking: "think claude" }, + { type: "text", text: "answer" }, + ], + }, + }, + ]), + ); + const wb = parseForeignSession( + "workbuddy", + jsonl([ + { type: "session-meta", sessionId: "wb" }, + { + type: "message", + id: "w1", + sessionId: "wb", + cwd: "/p", + role: "user", + content: [{ type: "input_text", text: `${control}\nWB ask` }], + }, + { type: "reasoning", id: "w2", sessionId: "wb", content: [{ type: "text", text: "think wb" }] }, + ]), + ); + const qoder = parseForeignSession( + "qoder", + jsonl([ + { + type: "user", + uuid: "q1", + parentUuid: null, + sessionId: "q", + cwd: "/p", + message: { content: "Qoder ask" }, + }, + { + type: "assistant", + uuid: "q2", + parentUuid: "q1", + sessionId: "q", + message: { content: [{ type: "thinking", thinking: "think qoder" }] }, + }, + { type: "active-leaf", sessionId: "q", leafUuid: "q2" }, + ]), + ); + for (const [parsed, thinking] of [ + [codex, "think codex"], + [claude, "think claude"], + [wb, "think wb"], + [qoder, "think qoder"], + ] as const) { + const blocks = parsed.entries.flatMap((e) => + e.type === "message" && e.message.role === "assistant" ? e.message.content : [], + ); + expect(blocks.filter((b) => b.type === "thinking").map((b) => b.type === "thinking" && b.thinking)).toContain( + thinking, + ); + } + expect(JSON.stringify([claude.entries, wb.entries])).not.toContain("command-name"); + }); + it("parses WorkBuddy messages and tools without activating source controls", () => { + const parsed = parseForeignSession( + "workbuddy", + jsonl([ + { type: "session-meta", sessionId: "wb" }, + { + type: "message", + id: "u", + sessionId: "wb", + cwd: "/project", + role: "user", + timestamp: 1792990000000, + content: [{ type: "input_text", text: "WorkBuddy task" }], + }, + { type: "ai-title", sessionId: "wb", aiTitle: "Named WorkBuddy task" }, + { type: "message", id: "s", role: "system", content: [{ type: "input_text", text: "SOURCE CONTROL" }] }, + { + type: "function_call", + id: "c", + callId: "call", + name: "write_file", + arguments: '{"path":"never-write.txt"}', + }, + { + type: "function_call_result", + id: "r", + callId: "call", + status: "failed", + output: { type: "text", text: "Permission denied" }, + }, + ]), + ); + expect(parsed.title).toBe("Named WorkBuddy task"); + expect(parsed.cwd).toBe("/project"); + expect(parsed.entries.every((entry) => entry.imported?.source === "workbuddy")).toBe(true); + expect(JSON.stringify(parsed.entries)).not.toContain("SOURCE CONTROL"); + const output = parsed.entries.find((entry) => entry.type === "message" && entry.message.role === "toolResult"); + expect(output?.type === "message" && output.message.role === "toolResult" && output.message.isError).toBe(true); + }); + + it("uses Qoder active-leaf rather than the last stored branch", () => { + const parsed = parseForeignSession( + "qoder", + jsonl([ + { type: "workspace-directories", sessionId: "qoder" }, + { type: "runtime-config", sessionId: "qoder", model: "never-select" }, + { + type: "user", + sessionId: "qoder", + cwd: "/project", + uuid: "u", + parentUuid: null, + message: { role: "user", content: "Qoder task" }, + }, + { + type: "assistant", + uuid: "kept", + parentUuid: "u", + message: { role: "assistant", content: [{ type: "text", text: "Current branch" }] }, + }, + { + type: "assistant", + uuid: "other", + parentUuid: "u", + message: { role: "assistant", content: [{ type: "text", text: "Unselected branch" }] }, + }, + { type: "active-leaf", sessionId: "qoder", leafUuid: "kept" }, + ]), + ); + expect(parsed.cwd).toBe("/project"); + expect(JSON.stringify(parsed.entries)).toContain("Current branch"); + expect(JSON.stringify(parsed.entries)).not.toContain("Unselected branch"); + expect(parsed.entries.every((entry) => entry.imported?.source === "qoder")).toBe(true); + }); + it("deduplicates record identities, not legitimate repeated text or event mirrors", () => { + const parsed = parseForeignSession( + "codex", + jsonl([ + meta(), + { type: "event_msg", timestamp, payload: { type: "user_message", message: "again" } }, + user("again", "one"), + reply("yes"), + user("again", "two"), + user("again", "two"), + ]), + ); + expect(parsed.entries.filter((e) => e.type === "message" && e.message.role === "user")).toHaveLength(2); + }); + + it("deduplicates tool protocol identities even when source item IDs are absent", () => { + const call = item({ type: "function_call", call_id: "stable-call", name: "Read", arguments: "{}" }); + const output = item({ type: "function_call_output", call_id: "stable-call", output: "evidence" }); + const parsed = parseForeignSession("codex", jsonl([meta(), user("inspect"), call, output, call, output])); + expect(parsed.entries.filter((e) => e.type === "message" && e.message.role === "assistant")).toHaveLength(1); + expect(parsed.entries.filter((e) => e.type === "message" && e.message.role === "toolResult")).toHaveLength(1); + }); + + it("keeps independent tool calls and results but never restores their execution protocol or usage", () => { + const parsed = parseForeignSession( + "codex", + jsonl([ + meta(), + user("inspect"), + item({ type: "function_call", call_id: "call-1", name: "exec_command", arguments: '{"cmd":"dir"}' }), + item({ + type: "function_call_output", + call_id: "call-1", + output: [ + { type: "text", text: "recorded output" }, + { type: "input_image", image_url: "data:image/png;base64,aW1hZ2U=" }, + ], + }), + item({ type: "message", role: "developer", content: [{ type: "input_text", text: "old controls" }] }), + ]), + ); + const context = buildSessionContext(parsed.entries); + expect(context.model).toBeNull(); + expect(JSON.stringify(context.messages)).toContain("Historical tool call"); + expect(JSON.stringify(context.messages)).toContain("recorded output"); + expect(JSON.stringify(context.messages)).toContain('"type":"image"'); + expect(JSON.stringify(context.messages)).not.toContain('"type":"toolCall"'); + expect(JSON.stringify(context.messages)).not.toContain("old controls"); + expect(collectSessionUsage(parsed.entries)).toMatchObject({ total: 0, missing: 0, invalid: 0 }); + }); + + it("retains readable pre-compaction history and uses the source summary for context", () => { + const parsed = parseForeignSession( + "codex", + jsonl([ + meta(), + user("old detail"), + reply("old reply"), + { + type: "compacted", + timestamp, + payload: { message: "Carry forward the task facts", replacement_history: [] }, + }, + user("continue"), + ]), + ); + expect(JSON.stringify(parsed.entries)).toContain("old detail"); + const context = JSON.stringify(buildSessionContext(parsed.entries).messages); + expect(context).toContain("Carry forward the task facts"); + expect(context).toContain("continue"); + expect(context).not.toContain("old detail"); + }); + + it("preserves retained compaction images and their captions without activating source controls", () => { + const parsed = parseForeignSession( + "codex", + jsonl([ + meta(), + user("old detail"), + { + type: "compacted", + timestamp, + payload: { + message: "Task summary", + replacement_history: [ + { + type: "message", + role: "user", + content: [ + { type: "input_text", text: "Use this image" }, + { type: "input_image", image_url: "data:image/png;base64,aW1hZ2U=" }, + ], + }, + { + type: "message", + role: "developer", + content: [{ type: "input_text", text: "excluded developer controls" }], + }, + ], + }, + }, + user("continue"), + ]), + ); + const context = JSON.stringify(buildSessionContext(parsed.entries).messages); + expect(context).toContain('"type":"image"'); + expect(context).toContain('"data":"aW1hZ2U="'); + expect(context).toContain("Use this image"); + expect(context).not.toContain("old detail"); + expect(context).not.toContain("excluded developer controls"); + expect(parsed.warnings).not.toContainEqual(expect.objectContaining({ code: "attachment_missing" })); + }); + + it("keeps the latest compaction attachments through rollback without counting them as new user turns", () => { + const compacted = (data: string) => ({ + type: "compacted", + timestamp, + payload: { + replacement_history: [ + { + type: "message", + role: "user", + content: [{ type: "input_image", image_url: `data:image/png;base64,${data}` }], + }, + ], + }, + }); + const parsed = parseForeignSession( + "codex", + jsonl([ + meta(), + user("old detail"), + compacted("b2xk"), + compacted("bmV3"), + user("rolled back turn"), + { type: "event_msg", timestamp, payload: { type: "thread_rolled_back", num_turns: 1 } }, + ]), + ); + const context = JSON.stringify(buildSessionContext(parsed.entries).messages); + expect(context).toContain('"data":"bmV3"'); + expect(context).not.toContain('"data":"b2xk"'); + expect(context).not.toContain("rolled back turn"); + expect(context).not.toContain("old detail"); + }); + + it.each([ + [true, "failed"], + [false, "completed"], + [undefined, "unknown"], + ] as const)("preserves Claude tool outcome %s in continuation context as %s", (isError, status) => { + const parsed = parseForeignSession( + "claude-code", + jsonl([ + { + type: "user", + uuid: "u", + sessionId: "cc", + message: { content: "run" }, + }, + { + type: "assistant", + uuid: "a", + parentUuid: "u", + sessionId: "cc", + message: { content: [{ type: "tool_use", id: "t", name: "Write", input: { path: "x" } }] }, + }, + { + type: "user", + uuid: "r", + parentUuid: "a", + sessionId: "cc", + message: { + content: [{ type: "tool_result", tool_use_id: "t", content: "same output", is_error: isError }], + }, + }, + ]), + ); + const context = JSON.stringify(buildSessionContext(parsed.entries).messages); + expect(context).toContain(`status: ${status}`); + expect(context).toContain("same output"); + expect(context).not.toContain('"type":"toolCall"'); + }); + + it("keeps Codex tool outcomes unknown when no explicit outcome is recorded", () => { + const parsed = parseForeignSession( + "codex", + jsonl([ + meta(), + user("run"), + item({ type: "function_call", call_id: "t", name: "Write", arguments: "{}" }), + item({ type: "function_call_output", call_id: "t", output: "done" }), + ]), + ); + expect(JSON.stringify(buildSessionContext(parsed.entries).messages)).toContain("status: unknown"); + }); + + it("applies rollback and rejects malformed or unknown record formats", () => { + const parsed = parseForeignSession( + "codex", + jsonl([ + meta(), + user("keep"), + reply("kept"), + user("remove"), + reply("removed"), + { type: "event_msg", timestamp, payload: { type: "thread_rolled_back", num_turns: 1 } }, + ]), + ); + expect(JSON.stringify(parsed.entries)).not.toContain('"remove"'); + expect(() => parseForeignSession("codex", `${jsonl([meta()])}{`)).toThrow("Invalid JSONL"); + expect(() => parseForeignSession("codex", jsonl([meta(), user("x"), { type: "future_record" }]))).toThrow( + "Unsupported", + ); + }); + + it("reads the selected Claude branch and does not mistake tool results for human instructions", () => { + const parsed = parseForeignSession( + "claude-code", + jsonl([ + { + type: "user", + uuid: "u", + parentUuid: null, + sessionId: "cc", + cwd: "/project", + timestamp, + message: { content: "inspect" }, + }, + { + type: "assistant", + uuid: "wrong", + parentUuid: "u", + sessionId: "cc", + timestamp, + message: { content: [{ type: "text", text: "other branch" }] }, + }, + { + type: "assistant", + uuid: "a", + parentUuid: "u", + sessionId: "cc", + timestamp, + message: { content: [{ type: "tool_use", id: "t", name: "Read", input: { file_path: "a.ts" } }] }, + }, + { + type: "user", + uuid: "r", + parentUuid: "a", + sessionId: "cc", + timestamp, + message: { + content: [{ type: "tool_result", tool_use_id: "t", content: "file contents", is_error: false }], + }, + }, + ]), + ); + expect(parsed.entries.filter((e) => e.type === "message" && e.message.role === "user")).toHaveLength(1); + expect(JSON.stringify(parsed.entries)).not.toContain("other branch"); + expect(JSON.stringify(buildSessionContext(parsed.entries).messages)).toContain("file contents"); + }); + + it("warns when a Claude compaction boundary prevents lossless history migration", () => { + const parsed = parseForeignSession( + "claude-code", + jsonl([ + { type: "system", subtype: "compact_boundary", sessionId: "cc", timestamp }, + { + type: "user", + uuid: "summary", + parentUuid: null, + sessionId: "cc", + timestamp, + isCompactSummary: true, + message: { content: "Carry forward the previous task facts" }, + }, + ]), + ); + expect(parsed.warnings).toContainEqual({ code: "claude_compaction", count: 2 }); + expect(JSON.stringify(buildSessionContext(parsed.entries).messages)).toContain("previous task facts"); + }); +}); + +describe("offline import lifecycle", () => { + it("discovers four mixed source formats once and imports new sources with durable provenance", async () => { + const f = await fixture(); + try { + await writeFile( + join(f.claudeRoot, "claude.jsonl"), + jsonl([{ type: "user", sessionId: "claude", cwd: f.cwd, uuid: "cu", message: { content: "Claude task" } }]), + ); + await writeFile( + join(f.workbuddyRoot, "wb.jsonl"), + jsonl([ + { type: "session-meta", sessionId: "wb" }, + { + type: "message", + id: "wu", + sessionId: "wb", + cwd: f.cwd, + role: "user", + content: [{ type: "input_text", text: "WorkBuddy task" }], + }, + { type: "ai-title", sessionId: "wb", aiTitle: "Named WorkBuddy task" }, + ]), + ); + await writeFile( + join(f.qoderRoot, "qoder.jsonl"), + jsonl([ + { type: "workspace-directories", sessionId: "qoder" }, + { type: "user", sessionId: "qoder", cwd: f.cwd, uuid: "qu", message: { content: "Qoder task" } }, + ]), + ); + const scan = await f.service.scan({ customRoot: f.root }); + expect(scan.errors).toEqual([]); + expect(scan.candidates.map((c) => c.source).sort()).toEqual(["claude-code", "codex", "qoder", "workbuddy"]); + for (const source of ["workbuddy", "qoder"] as const) { + const candidate = scan.candidates.find((c) => c.source === source); + expect(candidate?.cwd).toBe(f.cwd); + const preview = await f.service.preview(candidate?.id ?? ""); + const job = await finish( + f.service, + (await f.service.start([{ candidateId: preview.id, fingerprint: preview.fingerprint, cwd: f.cwd }])).id, + ); + expect(job.results[0].status).toBe("imported"); + expect(job.results[0].source).toBe(source); + expect(JSON.stringify(SessionManager.open(job.results[0].path as string).getEntries())).toContain(source); + } + } finally { + await f.service.close(); + } + }); + it("appends custom scans without invalidating existing reviewed candidates and deduplicates repeated paths", async () => { + const f = await fixture(); + try { + const original = (await f.service.scan(f)).candidates[0]; + const reviewed = await f.service.preview(original.id); + const customRoot = join(f.root, "extra"); + await mkdir(customRoot); + await writeFile(join(customRoot, "rollout-extra.jsonl"), jsonl([meta(f.cwd, "extra"), user("Extra history")])); + const extra = await f.service.scan({ customRoot }); + expect(extra.errors).toEqual([]); + expect(extra.candidates).toHaveLength(1); + expect(extra.candidates[0].sessionId).toBe("extra"); + const repeated = await f.service.scan({ customRoot }); + expect(repeated.candidates).toHaveLength(0); + expect(repeated.existingCount).toBe(1); + expect((await f.service.preview(original.id)).sessionId).toBe(original.sessionId); + const imported = await finish( + f.service, + (await f.service.start([{ candidateId: original.id, fingerprint: reviewed.fingerprint, cwd: f.cwd }])).id, + ); + expect(imported.results[0].status).toBe("imported"); + const missing = await f.service.scan({ customRoot: join(f.root, "missing") }); + expect(missing.errors.length).toBeGreaterThan(0); + expect((await f.service.preview(extra.candidates[0].id)).sessionId).toBe("extra"); + } finally { + await f.service.close(); + } + }); + it("returns readable scan titles without previewing or validating the full transcript", async () => { + const f = await fixture(); + const claude = join(f.claudeRoot, "named.jsonl"); + await writeFile( + claude, + jsonl([ + { + type: "user", + sessionId: "named", + cwd: f.cwd, + message: { content: [{ type: "text", text: "First task" }] }, + }, + { type: "summary", sessionId: "named", summary: "Useful summary" }, + { type: "custom-title", sessionId: "named", customTitle: "Explicit name" }, + ]) + "malformed unfinished tail", + ); + try { + const scan = await f.service.scan(f); + expect(scan.errors).toEqual([]); + expect(scan.candidates.find((c) => c.source === "codex")?.title).toBe("Keep migration facts"); + expect(scan.candidates.find((c) => c.sessionId === "named")?.title).toBe("Explicit name"); + expect(await readdir(f.agentDir).catch(() => [])).toEqual([]); + expect(await readFile(claude, "utf8")).toContain("malformed unfinished tail"); + } finally { + await f.service.close(); + } + }); + + it("bounds title discovery but lets preview replace first-user fallbacks with parsed titles", async () => { + const f = await fixture(); + await writeFile( + join(f.claudeRoot, "late.jsonl"), + jsonl([ + { type: "user", uuid: "u", sessionId: "late", cwd: f.cwd, message: { content: "Early task" } }, + { type: "ignored", padding: "x".repeat(1024 * 1024) }, + { type: "custom-title", sessionId: "late", customTitle: "Late explicit title" }, + ]), + ); + try { + const c = (await f.service.scan(f)).candidates.find((c) => c.sessionId === "late"); + expect(c?.title).toBe("Early task"); + expect((await f.service.preview(c?.id ?? "")).title).toBe("Late explicit title"); + } finally { + await f.service.close(); + } + }); + + it("prefers reliable Codex index and metadata titles over first user text", async () => { + const f = await fixture(); + await writeFile( + f.source, + jsonl([{ ...meta(f.cwd), payload: { ...meta(f.cwd).payload, title: "Metadata title" } }, user("Task text")]), + ); + try { + let c = (await f.service.scan(f)).candidates[0]; + expect(c.title).toBe("Metadata title"); + expect((await f.service.preview(c.id)).title).toBe("Metadata title"); + await writeFile( + join(f.codexRoot, "session_index.jsonl"), + jsonl([{ id: c.sessionId, thread_name: "Index title" }]), + ); + c = (await f.service.scan(f)).candidates[0]; + expect(c.title).toBe("Index title"); + expect((await f.service.preview(c.id)).title).toBe("Index title"); + } finally { + await f.service.close(); + } + }); + it("records selected destination cwd for imported, skipped and failed outcomes", async () => { + const f = await fixture(); + const destination = join(f.root, "destination"); + await mkdir(destination); + try { + const c = (await f.service.scan(f)).candidates[0]; + const p = await f.service.preview(c.id); + const selection = { candidateId: c.id, fingerprint: p.fingerprint, cwd: destination }; + for (const status of ["imported", "skipped"] as const) { + const job = await finish(f.service, (await f.service.start([selection])).id); + expect(job.results[0]).toMatchObject({ status, cwd: destination }); + } + const missing = join(f.root, "missing"); + const failed = await finish(f.service, (await f.service.start([{ ...selection, cwd: missing }])).id); + expect(failed.results[0]).toMatchObject({ status: "failed", cwd: missing }); + await f.service.close(); + const restarted = new SessionImportService({ agentDir: f.agentDir }); + try { + expect((await restarted.getJob(failed.id)).results[0].cwd).toBe(missing); + } finally { + await restarted.close(); + } + } finally { + await f.service.close(); + } + }); + + it("recovers older result cwd, title and provenance only from a contained bounded native header", async () => { + const f = await fixture(); + const root = join(f.agentDir, "sessions", "imported"); + await mkdir(root, { recursive: true }); + const native = join(root, "native.jsonl"); + const outside = join(f.root, "outside.jsonl"); + const header = jsonl([ + { type: "session", cwd: f.cwd }, + { + type: "message", + message: { role: "user", content: "First native task" }, + imported: { source: "codex", sessionId: "s", itemId: "u" }, + }, + { type: "session_info", name: "[codex] Saved name" }, + ]); + await writeFile(native, header); + await writeFile(outside, header); + const linkPath = join(root, "escape.jsonl"); + await symlink(outside, linkPath); + const bounded = join(root, "bounded.jsonl"); + await writeFile( + bounded, + jsonl([ + { type: "session", cwd: f.cwd }, + { type: "ignored", padding: "x".repeat(1024 * 1024) }, + { type: "session_info", name: "Not read" }, + ]), + ); + const id = "11111111-1111-4111-a111-111111111111"; + const journal = join(f.agentDir, "session-imports", `${id}.json`); + await mkdir(join(f.agentDir, "session-imports")); + const job = { + id, + createdAt: timestamp, + status: "completed", + total: 6, + results: [ + { candidateId: "old", status: "imported", path: native }, + { + candidateId: "source-known", + status: "skipped", + path: native, + source: "claude-code", + title: "Existing title", + }, + { candidateId: "outside", status: "imported", path: outside }, + { candidateId: "symlink", status: "imported", path: linkPath }, + { candidateId: "bounded", status: "imported", path: bounded }, + { candidateId: "deleted", status: "imported", path: join(root, "missing.jsonl") }, + ], + }; + await writeFile(journal, JSON.stringify(job)); + try { + const results = (await f.service.getJob(id)).results; + expect(results[0]).toMatchObject({ cwd: f.cwd, title: "Saved name", source: "codex" }); + expect(results[1]).toMatchObject({ cwd: f.cwd, title: "Existing title", source: "claude-code" }); + for (const index of [2, 3, 5]) { + expect(results[index].cwd).toBeUndefined(); + expect(results[index].title).toBeUndefined(); + expect(results[index].source).toBeUndefined(); + } + expect(results[4]).toMatchObject({ cwd: f.cwd }); + expect(results[4].title).toBeUndefined(); + expect(results[4].source).toBeUndefined(); + await writeFile(native, header.split("\n").slice(0, 2).join("\n")); + expect((await f.service.getJob(id)).results[0].title).toBe("First native task"); + for (const cwd of [42, "relative", "", null, "/bad\0path"]) { + await writeFile(journal, JSON.stringify({ ...job, results: [{ ...job.results[0], cwd }] })); + await expect(f.service.getJob(id)).rejects.toThrow("Invalid persisted import result"); + } + } finally { + await f.service.close(); + } + }); + + it.each([5000, 1024 * 1024])( + "discovers and imports Claude metadata in a large first record with %s extra bytes", + async (extra) => { + const f = await fixture(); + const first = { + type: "user", + uuid: "long-user", + sessionId: "long-claude", + cwd: f.cwd, + timestamp, + message: { content: "" }, + }; + const emptyRecord = JSON.stringify(first); + const contentStart = emptyRecord.indexOf('"content":""') + '"content":"'.length; + const prefixBytes = Buffer.byteLength(emptyRecord.slice(0, contentStart)); + first.message.content = `${"x".repeat(65535 - prefixBytes)}\u754c${"x".repeat(extra)}`; + const input = jsonl([first]); + // Include a record without a trailing newline; split UTF-8 must not be decoded per chunk. + await writeFile(join(f.claudeRoot, "long.jsonl"), input.trimEnd()); + try { + const scan = await f.service.scan(f); + expect(scan.errors).toEqual([]); + const c = scan.candidates.find((candidate) => candidate.sessionId === "long-claude"); + expect(c).toBeDefined(); + const p = await f.service.preview(c?.id ?? ""); + const job = await finish( + f.service, + (await f.service.start([{ candidateId: p.id, fingerprint: p.fingerprint, cwd: f.cwd }])).id, + ); + expect(job.results[0].status).toBe("imported"); + const messages = SessionManager.open(job.results[0].path as string).buildSessionContext().messages; + expect(JSON.stringify(messages)).toContain("\u754c"); + expect(JSON.stringify(messages)).not.toContain("\uFFFD"); + } finally { + await f.service.close(); + } + }, + ); + + it("scans a selected date subtree but excludes archive subtrees even when selected directly", async () => { + const f = await fixture(); + const dateRoot = join(f.codexRoot, "sessions", "2026"); + const month = join(dateRoot, "10"); + const archivedRoot = join(f.codexRoot, "archived_sessions", "2026"); + await mkdir(month, { recursive: true }); + await mkdir(archivedRoot, { recursive: true }); + await writeFile(join(month, "rollout-day.jsonl"), jsonl([meta(f.cwd), user("dated")])); + await writeFile(join(archivedRoot, "rollout-archive.jsonl"), "{ malformed archive"); + try { + expect((await f.service.scan({ ...f, codexRoot: dateRoot })).candidates).toHaveLength(1); + const scan = await f.service.scan({ ...f, codexRoot: archivedRoot }); + expect(scan).toEqual({ + existingCount: 0, + roots: { + codex: await realpath(archivedRoot), + "claude-code": await realpath(f.claudeRoot), + workbuddy: await realpath(f.workbuddyRoot), + qoder: await realpath(f.qoderRoot), + }, + candidates: [], + errors: [], + }); + } finally { + await f.service.close(); + } + }); + + it("owns submitted selections and source references across later scans", async () => { + const f = await fixture(); + try { + const candidate = (await f.service.scan(f)).candidates[0]; + const p = await f.service.preview(candidate.id); + const selections = [{ candidateId: candidate.id, fingerprint: p.fingerprint, cwd: f.cwd }]; + const pending = f.service.start(selections); + selections[0].candidateId = "caller-mutated"; + selections[0].cwd = join(f.root, "missing"); + await f.service.scan({ ...f, codexRoot: f.claudeRoot }); + const job = await finish(f.service, (await pending).id); + expect(job.results[0]).toMatchObject({ candidateId: candidate.id, status: "imported" }); + } finally { + await f.service.close(); + } + }); + + it("excludes archives before parsing them and offers an explicit opt-in", async () => { + const f = await fixture(); + await mkdir(join(f.codexRoot, "archived_sessions")); + await writeFile( + join(f.codexRoot, "archived_sessions", "rollout-archive.jsonl"), + jsonl([meta(f.cwd, "archived"), user("old")]), + ); + try { + const scan = await f.service.scan(f); + expect(scan.candidates).toHaveLength(1); + const all = await f.service.scan({ ...f, includeArchived: true }); + expect(all.candidates).toHaveLength(2); + expect(all.candidates.find((c) => c.sessionId === "archived")?.archived).toBe(true); + } finally { + await f.service.close(); + } + }); + + it("atomically publishes native sessions, skips repeated snapshots, preserves new versions and leaves sources untouched", async () => { + const f = await fixture(); + const original = await readFile(f.source); + try { + const candidate = (await f.service.scan(f)).candidates[0]; + const p = await f.service.preview(candidate.id); + const selection = { candidateId: candidate.id, fingerprint: p.fingerprint, cwd: f.cwd }; + const first = await finish(f.service, (await f.service.start([selection])).id); + expect(first.results[0].status).toBe("imported"); + const path = first.results[0].path; + expect(path).toBeTruthy(); + const session = SessionManager.open(path as string); + expect(JSON.stringify(session.buildSessionContext().messages)).toContain("migration facts"); + session.appendMessage({ role: "user", content: "Continue in SCD", timestamp: Date.now() }); + const second = await finish(f.service, (await f.service.start([selection])).id); + expect(second.results[0].status).toBe("skipped"); + expect(await readFile(path as string, "utf8")).toContain("Continue in SCD"); + expect(await readFile(f.source)).toEqual(original); + await writeFile(f.source, `${original.toString("utf8")}${jsonl([user("new source turn")])}`); + const stale = await finish(f.service, (await f.service.start([selection])).id); + expect(stale.results[0].status).toBe("skipped"); + const updated = await f.service.preview(candidate.id); + const third = await finish( + f.service, + (await f.service.start([{ ...selection, fingerprint: updated.fingerprint }])).id, + ); + expect(third.results[0].sessionId).not.toBe(first.results[0].sessionId); + expect(f.events).toHaveLength(4); + expect(await readdir(join(f.agentDir, "sessions", "imported"))).toHaveLength(2); + } finally { + await f.service.close(); + } + }); + + it("imports the reviewed snapshot even when an active source appends metadata and messages", async () => { + const f = await fixture(); + try { + const c = (await f.service.scan(f)).candidates[0]; + const p = await f.service.preview(c.id); + await writeFile( + f.source, + `${await readFile(f.source, "utf8")}${jsonl([ + { type: "event_msg", timestamp, payload: { type: "token_count", info: {} } }, + user("not reviewed"), + ])}`, + ); + const job = await finish( + f.service, + (await f.service.start([{ candidateId: c.id, fingerprint: p.fingerprint, cwd: f.cwd }])).id, + ); + expect(job.results[0].status).toBe("imported"); + const saved = await readFile(job.results[0].path as string, "utf8"); + expect(saved).toContain("Keep migration facts"); + expect(saved).not.toContain("not reviewed"); + expect(saved).toContain(p.fingerprint); + } finally { + await f.service.close(); + } + }); + + it("serializes concurrent duplicate jobs and restores persisted results after restart", async () => { + const f = await fixture(); + const candidate = (await f.service.scan(f)).candidates[0]; + const p = await f.service.preview(candidate.id); + const selection = { candidateId: candidate.id, fingerprint: p.fingerprint, cwd: f.cwd }; + const jobs = await Promise.all([f.service.start([selection]), f.service.start([selection])]); + await Promise.all(jobs.map((j) => finish(f.service, j.id))); + await f.service.close(); + const results = await Promise.all(jobs.map((j) => f.service.getJob(j.id))); + expect(results.flatMap((j) => j.results.map((r) => r.status)).sort()).toEqual(["imported", "skipped"]); + const restarted = new SessionImportService({ agentDir: f.agentDir }); + try { + expect(await restarted.listJobs()).toHaveLength(2); + await writeFile( + join(f.agentDir, "session-imports", `${jobs[0].id}.json`), + JSON.stringify({ ...jobs[0], status: "running" }), + ); + expect((await restarted.getJob(jobs[0].id)).status).toBe("interrupted"); + } finally { + await restarted.close(); + } + }); + + it("rejects expired or unreviewed fingerprints without creating jobs", async () => { + const f = await fixture(); + try { + const c = (await f.service.scan(f)).candidates[0]; + const p = await f.service.preview(c.id); + await expect( + f.service.start([{ candidateId: c.id, fingerprint: "f".repeat(64), cwd: f.cwd }]), + ).rejects.toThrow("Preview expired"); + const now = Date.now(); + const clock = vi.spyOn(Date, "now").mockReturnValue(now + 16 * 60 * 1000); + try { + await expect( + f.service.start([{ candidateId: c.id, fingerprint: p.fingerprint, cwd: f.cwd }]), + ).rejects.toThrow("Preview expired"); + } finally { + clock.mockRestore(); + } + expect(await f.service.listJobs()).toHaveLength(0); + const refreshed = await f.service.preview(c.id); + const job = await finish( + f.service, + (await f.service.start([{ candidateId: c.id, fingerprint: refreshed.fingerprint, cwd: f.cwd }])).id, + ); + expect(job.results[0].status).toBe("imported"); + } finally { + await f.service.close(); + } + }); + + it("reports durable imports as successful even if the search-index callback fails", async () => { + const f = await fixture(); + const service = new SessionImportService({ + agentDir: f.agentDir, + onPersisted: () => { + throw new Error("index unavailable"); + }, + }); + try { + const c = (await service.scan(f)).candidates[0]; + const p = await service.preview(c.id); + const job = await finish( + service, + (await service.start([{ candidateId: c.id, fingerprint: p.fingerprint, cwd: f.cwd }])).id, + ); + expect(job.results[0]).toMatchObject({ status: "imported", title: p.title }); + expect(job.results[0].warning).toContain("index unavailable"); + expect(SessionManager.open(job.results[0].path as string).buildSessionContext().messages).not.toHaveLength(0); + } finally { + await service.close(); + await f.service.close(); + } + }); + + it("validates source bounds, destination and snapshot fingerprints", async () => { + const f = await fixture(); + try { + const c = (await f.service.scan(f)).candidates[0]; + const p = await f.service.preview(c.id); + expect(p.fingerprint).toBe( + createHash("sha256") + .update(await readFile(f.source)) + .digest("hex"), + ); + await expect(f.service.start([{ candidateId: c.id, fingerprint: "bad", cwd: f.cwd }])).rejects.toThrow( + "Invalid selection", + ); + const job = await finish( + f.service, + (await f.service.start([{ candidateId: c.id, fingerprint: p.fingerprint, cwd: join(f.root, "missing") }])) + .id, + ); + expect(job.results[0].status).toBe("failed"); + await expect(f.service.preview("unknown")).rejects.toThrow("expired"); + } finally { + await f.service.close(); + } + }); + + it("copies only contained image attachments and fingerprints their contents", async () => { + const f = await fixture(); + const image = join(f.codexRoot, "image.png"); + await writeFile(image, Buffer.from("89504e470d0a1a0a01020304", "hex")); + await writeFile( + f.source, + jsonl([ + meta(f.cwd), + item({ + type: "message", + role: "user", + content: [ + { type: "input_text", text: "inspect image" }, + { type: "input_image", image_url: image }, + { type: "input_image", image_url: join(f.root, "outside.png") }, + ], + }), + ]), + ); + try { + const c = (await f.service.scan(f)).candidates[0]; + const p = await f.service.preview(c.id); + expect(p.warnings).toContainEqual({ code: "attachment_missing", count: 1 }); + await writeFile(image, Buffer.from("89504e470d0a1a0a05060708", "hex")); + const changed = await f.service.preview(c.id); + expect(changed.fingerprint).not.toBe(p.fingerprint); + const reviewed = await finish( + f.service, + (await f.service.start([{ candidateId: c.id, fingerprint: p.fingerprint, cwd: f.cwd }])).id, + ); + expect(await readFile(reviewed.results[0].path as string, "utf8")).toContain( + Buffer.from("89504e470d0a1a0a01020304", "hex").toString("base64"), + ); + const job = await finish( + f.service, + (await f.service.start([{ candidateId: c.id, fingerprint: changed.fingerprint, cwd: f.cwd }])).id, + ); + expect(await readFile(job.results[0].path as string, "utf8")).toContain('"mimeType":"image/png"'); + } finally { + await f.service.close(); + } + }); + + it("reopens compacted attachments and failed tool evidence into a real continuation request", async () => { + const f = await fixture(); + const image = Buffer.from("89504e470d0a1a0a01020304", "hex"); + await writeFile(join(f.codexRoot, "retained.png"), image); + await writeFile( + f.source, + jsonl([ + meta(f.cwd), + user("old detail"), + { + type: "compacted", + timestamp, + payload: { + message: "Continue inspecting the retained image", + replacement_history: [ + { + type: "message", + role: "user", + content: [ + { type: "input_text", text: "Retained caption" }, + { type: "input_image", image_url: "retained.png" }, + { type: "input_image", image_url: "missing.png" }, + ], + }, + ], + }, + }, + ]), + ); + const h = await createHarness({ tools: [], settings: { compaction: { enabled: false } } }); + try { + const c = (await f.service.scan(f)).candidates[0]; + const p = await f.service.preview(c.id); + expect(p.warnings).toContainEqual({ code: "attachment_missing", count: 1 }); + const job = await finish( + f.service, + (await f.service.start([{ candidateId: c.id, fingerprint: p.fingerprint, cwd: f.cwd }])).id, + ); + expect(job.results[0].status).toBe("imported"); + const reopened = SessionManager.open(job.results[0].path as string); + const failedTool = parseForeignSession( + "claude-code", + jsonl([ + { type: "user", uuid: "u", sessionId: "cc", message: { content: "write" } }, + { + type: "assistant", + uuid: "a", + parentUuid: "u", + sessionId: "cc", + message: { content: [{ type: "tool_use", id: "t", name: "Write", input: {} }] }, + }, + { + type: "user", + uuid: "r", + parentUuid: "a", + sessionId: "cc", + message: { content: [{ type: "tool_result", tool_use_id: "t", content: "done", is_error: true }] }, + }, + ]), + ); + h.session.agent.state.messages = [ + ...reopened.buildSessionContext().messages, + ...buildSessionContext(failedTool.entries).messages, + ]; + let observed = false; + h.setResponses([ + (context) => { + const request = JSON.stringify(context.messages); + expect(request).toContain('"type":"image"'); + expect(request).toContain(image.toString("base64")); + expect(request).toContain("Retained caption"); + expect(request).toContain("status: failed"); + expect(request).not.toContain("old detail"); + expect(request).not.toContain('"type":"toolCall"'); + observed = true; + return fauxAssistantMessage("Verified retained image and failed historical tool"); + }, + ]); + await h.session.prompt("Continue from the migrated evidence."); + expect(observed).toBe(true); + } finally { + h.cleanup(); + await f.service.close(); + } + }); + + it("cancels remaining work and does not publish half-written sessions", async () => { + const f = await fixture(); + try { + const c = (await f.service.scan(f)).candidates[0]; + const p = await f.service.preview(c.id); + const selections = Array.from({ length: 100 }, () => ({ + candidateId: c.id, + fingerprint: p.fingerprint, + cwd: f.cwd, + })); + const job = await f.service.start(selections); + await f.service.cancel(job.id); + const settled = await finish(f.service, job.id); + expect(settled.status).toBe("cancelled"); + expect(settled.results.length).toBeLessThan(100); + } finally { + await f.service.close(); + } + }); + + it("continues a reopened import through a faux provider and executes only a new native tool", async () => { + const f = await fixture(); + await writeFile( + f.source, + jsonl([ + meta(f.cwd), + user("Migration proof must be written only after continuation"), + item({ + type: "function_call", + call_id: "old-tool", + name: "exec_command", + arguments: '{"cmd":"never replay"}', + }), + item({ type: "function_call_output", call_id: "old-tool", output: "historical evidence" }), + ]), + ); + const h = await createHarness({ tools: [createWriteTool(f.cwd)], settings: { compaction: { enabled: false } } }); + try { + const c = (await f.service.scan(f)).candidates[0]; + const p = await f.service.preview(c.id); + const job = await finish( + f.service, + (await f.service.start([{ candidateId: c.id, fingerprint: p.fingerprint, cwd: f.cwd }])).id, + ); + const loaded = SessionManager.open(job.results[0].path as string).buildSessionContext(); + h.session.agent.state.messages = loaded.messages; + let observed = false; + h.setResponses([ + (context) => { + const request = JSON.stringify(context.messages); + expect(request).toContain("historical evidence"); + expect(request).toContain("Migration proof"); + expect(request).not.toContain('"type":"toolCall"'); + observed = true; + return fauxAssistantMessage( + fauxToolCall("write", { path: "migration-proof.txt", content: "continued safely" }), + { stopReason: "toolUse" }, + ); + }, + fauxAssistantMessage("Continuation complete"), + ]); + await h.session.prompt("Continue and write the migration proof."); + expect(observed).toBe(true); + expect(await readFile(join(f.cwd, "migration-proof.txt"), "utf8")).toBe("continued safely"); + } finally { + h.cleanup(); + await f.service.close(); + } + }); +});