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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import type { UiCatalog, UiLocale } from '@maka/core/ui-locale';
*/
type ExternalSessionImportCopy = {
sourceLabel: string;
codex: string;
/** Display names by adapter id. An id with no entry falls back to the id
* itself, which is legible enough to ship and obvious enough to fix. */
sourceNames: Readonly<Record<string, string>>;
includeArchived: string;
loading: string;
listAria: string;
Expand Down Expand Up @@ -59,7 +61,7 @@ type ExternalSessionImportCopy = {
const COPY = {
zh: {
sourceLabel: '来源',
codex: 'Codex',
sourceNames: { codex: 'Codex', 'claude-code': 'Claude Code' },
includeArchived: '包含已归档的对话',
loading: '正在读取外部对话…',
listAria: '可导入的对话',
Expand Down Expand Up @@ -102,7 +104,7 @@ const COPY = {
},
en: {
sourceLabel: 'Source',
codex: 'Codex',
sourceNames: { codex: 'Codex', 'claude-code': 'Claude Code' },
includeArchived: 'Include archived conversations',
loading: 'Reading external conversations…',
listAria: 'Conversations available to import',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,7 @@ export function ImportTasksSettingsPage(props: {
title={copy.sourceLabel}
description={
adapterIds.length === 1 && adapterId !== null
? sourceLabel(adapterId, copy.codex)
? sourceLabel(adapterId, copy.sourceNames)
: undefined
}
variant="bare"
Expand All @@ -503,7 +503,7 @@ export function ImportTasksSettingsPage(props: {
isDisabled={catalogLoading}
>
{adapterIds.map((id) => (
<SegmentedControlItem key={id} value={id} label={sourceLabel(id, copy.codex)} />
<SegmentedControlItem key={id} value={id} label={sourceLabel(id, copy.sourceNames)} />
))}
</SegmentedControl>
)}
Expand Down Expand Up @@ -714,6 +714,6 @@ export function ImportTasksSettingsPage(props: {
);
}

function sourceLabel(adapterId: string, codexLabel: string): string {
return adapterId === 'codex' ? codexLabel : adapterId;
function sourceLabel(adapterId: string, names: Readonly<Record<string, string>>): string {
return names[adapterId] ?? adapterId;
}
142 changes: 142 additions & 0 deletions packages/runtime/src/__tests__/runtime-ledger-repair.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,145 @@ test('repairs imported transcript turns into provider-neutral canonical history'
await rm(root, { recursive: true, force: true });
}
});

/**
* The Claude Code adapter records a turn the transcript stops inside as
* `aborted` with `abortSource: 'external_session_snapshot'`, rather than
* emitting no terminal state at all.
*
* The difference is only visible here. An adapter-level assertion can show
* which `turn_state` was emitted, but not what the Ledger does with it — and
* what it does is the whole reason the choice matters: an uncorroborated
* terminal is refused and the repair path writes `failed`, so a transcript
* that was merely cut short would import as internal corruption.
*/
test('an imported snapshot cutoff survives materialization as aborted', async () => {
const root = await mkdtemp(join(tmpdir(), 'maka-snapshot-cutoff-'));
const sessions = createSessionStore(root);
const runs = createSqliteAgentRunStore(root);
const runtimeEvents = createSqliteRuntimeStore(join(root, 'runtime.sqlite'));
let sequence = 0;
const newId = () => `cutoff-${++sequence}`;

try {
const ts = Date.now() + 86_400_000;
const cutoffMessages: StoredMessage[] = [
{ type: 'user', id: 'c-user', turnId: 'turn-cut', ts, text: 'read the file' },
{
type: 'assistant',
id: 'c-assistant',
turnId: 'turn-cut',
ts,
text: 'Reading it now.',
contentOrder: ['text'],
modelId: 'claude-opus-5',
},
{
type: 'tool_call',
id: 'c-tool',
turnId: 'turn-cut',
ts,
toolName: 'Read',
args: { path: '/repo/a.ts' },
},
// No tool_result: the transcript ends between the call and its answer.
{
type: 'turn_state',
id: 'c-state',
turnId: 'turn-cut',
ts,
status: 'aborted',
abortedAt: ts,
abortSource: 'external_session_snapshot',
partialOutputRetained: true,
},
];
const session = await sessions.createImportedSession(
{
cwd: '/repo',
llmConnectionSlug: 'anthropic',
model: 'claude-opus-5',
permissionMode: 'ask',
},
cutoffMessages,
{ adapterId: 'claude-code', sourceSessionId: 'cut-1' },
);
const repair = new RuntimeLedgerRepair({
runStore: runs,
runtimeEventStore: runtimeEvents,
readMessages: (sessionId) => sessions.readMessages(sessionId),
appendMessage: (sessionId, message) => sessions.appendMessage(sessionId, message),
appendTurnState: async () => undefined,
newId,
now: () => 100,
});

await repair.materializeTranscriptLedger(session);

const [run] = await runs.listSessionRuns(session.id);
assert.ok(run);
// `cancelled`, not `failed`: the Ledger accepted the recorded abort. Before
// the adapter emitted one, this same transcript materialized as
// `failed / missing_terminal_event`.
assert.equal(run.status, 'cancelled');
assert.notEqual(run.failureClass, 'missing_terminal_event');
} finally {
await runtimeEvents.close?.();
await rm(root, { recursive: true, force: true });
}
});

test('an imported turn with no terminal state is repaired to failed', async () => {
// The behaviour the adapter now avoids, pinned so the reason for emitting a
// cutoff cannot quietly stop being true.
const root = await mkdtemp(join(tmpdir(), 'maka-missing-terminal-'));
const sessions = createSessionStore(root);
const runs = createSqliteAgentRunStore(root);
const runtimeEvents = createSqliteRuntimeStore(join(root, 'runtime.sqlite'));
let sequence = 0;
const newId = () => `missing-${++sequence}`;

try {
const ts = Date.now() + 86_400_000;
const session = await sessions.createImportedSession(
{
cwd: '/repo',
llmConnectionSlug: 'anthropic',
model: 'claude-opus-5',
permissionMode: 'ask',
},
[
{ type: 'user', id: 'm-user', turnId: 'turn-missing', ts, text: 'read the file' },
{
type: 'assistant',
id: 'm-assistant',
turnId: 'turn-missing',
ts,
text: 'Reading it now.',
contentOrder: ['text'],
modelId: 'claude-opus-5',
},
],
{ adapterId: 'claude-code', sourceSessionId: 'missing-1' },
);
const repair = new RuntimeLedgerRepair({
runStore: runs,
runtimeEventStore: runtimeEvents,
readMessages: (sessionId) => sessions.readMessages(sessionId),
appendMessage: (sessionId, message) => sessions.appendMessage(sessionId, message),
appendTurnState: async () => undefined,
newId,
now: () => 100,
});

await repair.materializeTranscriptLedger(session);

const [run] = await runs.listSessionRuns(session.id);
assert.ok(run);
assert.equal(run.status, 'failed');
assert.equal(run.failureClass, 'missing_terminal_event');
} finally {
await runtimeEvents.close?.();
await rm(root, { recursive: true, force: true });
}
});
Loading