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
94 changes: 91 additions & 3 deletions apps/desktop/src/main/__tests__/import-tasks-settings-page.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { createRoot, type Root } from 'react-dom/client';
import { AstryxLocaleProvider, LocaleProvider } from '@maka/ui';
import type { DesktopRuntimeHostRef } from '../../preload/bridge-contract.js';
import type { DesktopExternalSessionCatalogItem } from '../../preload/external-session-catalog.js';
import type { ExternalSessionImportFailureReason } from '../../preload/external-session-import-result.js';
import { ImportTasksSettingsPage } from '../../renderer/settings/import-tasks-settings-page.js';
import { RuntimeHostSettingsTarget } from '../../renderer/settings/runtime-host-settings-target.js';

Expand Down Expand Up @@ -108,6 +109,52 @@ describe('ImportTasksSettingsPage durable import state', () => {
await act(async () => harness.root.unmount());
});

it('shows an actionable banner and does not re-read the catalog when no model is usable', async () => {
const harness = await renderPage({
catalog: catalog(externalSession()),
importResult: { ok: false, reason: 'no_model' },
});

const importButton = buttonWithText(harness.container, 'Import');
assert.ok(importButton);
await act(async () => {
importButton.click();
await Promise.resolve();
await Promise.resolve();
});

assert.match(harness.container.textContent, /No usable model connection/);
// A clean model failure is not a maybe-landed task: no recovery re-read, and
// none of the unknown-outcome copy.
assert.doesNotMatch(harness.container.textContent, /Check the import result/);
assert.deepEqual(harness.hostCalls(), [
{ operation: 'listSources', host: TEST_RUNTIME_HOST },
{ operation: 'list', host: TEST_RUNTIME_HOST },
{ operation: 'import', host: TEST_RUNTIME_HOST },
]);

await act(async () => harness.root.unmount());
});

it('shows a source-unreadable banner when the conversation cannot be converted', async () => {
const harness = await renderPage({
catalog: catalog(externalSession()),
importResult: { ok: false, reason: 'source_unreadable' },
});

const importButton = buttonWithText(harness.container, 'Import');
assert.ok(importButton);
await act(async () => {
importButton.click();
await Promise.resolve();
await Promise.resolve();
});

assert.match(harness.container.textContent, /could not be read or converted/);

await act(async () => harness.root.unmount());
});

it('uses catalog in-flight state after remount to disable the source row', async () => {
const harness = await renderPage({
catalog: {
Expand Down Expand Up @@ -1202,14 +1249,14 @@ async function renderPage(options: {
adapterIds?: string[];
bySource?: Record<string, Array<CatalogResult | Error | Promise<CatalogResult>>>;
importResult?:
| { ok: false; reason: 'commit_outcome_unknown' }
| Promise<{ ok: false; reason: 'commit_outcome_unknown' }>;
| { ok: false; reason: ExternalSessionImportFailureReason }
| Promise<{ ok: false; reason: ExternalSessionImportFailureReason }>;
/**
* Per-source answers for a batch: `ok` lands, `unknown` is the Host not
* answering, `throw` is a rejection. Keyed by source session id, because a
* batch is exactly the case where the ids must not share one answer.
*/
importBySource?: Record<string, 'ok' | 'unknown' | 'throw'>;
importBySource?: Record<string, 'ok' | 'unknown' | 'throw' | 'no_model' | 'source_unreadable'>;
onOpenImported?: (sessionId: string) => void;
locale?: 'en' | 'zh';
}): Promise<{
Expand Down Expand Up @@ -1288,6 +1335,9 @@ async function renderPage(options: {
const perSource = options.importBySource?.[sourceSessionId];
if (perSource === 'throw') throw new Error(`import-failed:${sourceSessionId}`);
if (perSource === 'unknown') return { ok: false, reason: 'commit_outcome_unknown' };
if (perSource === 'no_model' || perSource === 'source_unreadable') {
return { ok: false, reason: perSource };
}
if (perSource === 'ok') {
return { ok: true, session: { id: `imported-${sourceSessionId}` } };
}
Expand Down Expand Up @@ -1498,6 +1548,44 @@ describe('ImportTasksSettingsPage batch import', () => {
assert.match(text, /unconfirmed|Unconfirmed|outcome/i);
});

it('counts code-classified batch failures as failed, not unconfirmed, and raises the model banner', async () => {
// Before the fix, no_model / source_unreadable were swept into the
// maybe-landed "unconfirmed" bucket alongside commit_outcome_unknown: no
// actionable banner, the recovery/retry path offered, and the summary could
// read as success. They are definite failures — counted as failed, never
// offered recovery. no_model additionally raises its actionable banner.
const { container } = await renderPage({
catalog: {
sessions: [
externalSession({ id: 'blocked', name: 'Blocked' }),
externalSession({ id: 'unreadable', name: 'Unreadable' }),
],
nextCursor: null,
},
importBySource: { blocked: 'no_model', unreadable: 'source_unreadable' },
});

await tick(masterBox(container), true);
const run = buttonWithText(container, 'Import selected');
assert.ok(run);
await act(async () => {
run.click();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});

const text = container.textContent ?? '';
// Both are definite failures: the summary counts them, none imported.
assert.match(text, /No conversation was imported/);
assert.match(text, /2 more could not be imported/);
// Not the maybe-landed path: no unconfirmed/recovery banner is offered.
assert.doesNotMatch(text, /Check the import result/);
// The one globally-actionable reason surfaces its banner.
assert.match(text, /No usable model connection/);
});

it('spins only the conversion in flight, not every queued row', async () => {
// A spinner claims something is happening now. Marking every selected row
// would put one on rows the batch has not reached, and on rows it already
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,94 @@ test('an uncertain commit still asks the shell to re-read the catalog', async ()
assert.deepEqual(events, [{ reason: 'created', sessionId: undefined }]);
});

test('maps a no-usable-model failure to a distinct, non-recovering reason', async () => {
const events: unknown[] = [];
const ipc = ipcHarness();
registerRuntimeHostExternalSessionsIpc(
{
client: clientFixture({
importExternalSession: async () => {
throw new RuntimeHostOperationError(
'external-session.import',
'model_unavailable',
'No usable Session model connection is available for import',
);
},
}),
emitSessionsChanged: (reason, sessionId) => events.push({ reason, sessionId }),
},
ipc,
);

assert.deepEqual(
await ipc.invoke('external-sessions:import', {
adapterId: 'codex',
sourceSessionId: 'source-1',
}),
{ ok: false, reason: 'no_model' },
);
// A model-resolution failure never touched the catalog, so nothing to re-read.
assert.deepEqual(events, []);
});

test('maps a pre-commit conversion failure to source_unreadable', async () => {
const ipc = ipcHarness();
registerRuntimeHostExternalSessionsIpc(
{
client: clientFixture({
importExternalSession: async () => {
throw new RuntimeHostOperationError(
'external-session.import',
'source_unreadable',
'External Session could not be read or converted',
);
},
}),
emitSessionsChanged() {},
},
ipc,
);

assert.deepEqual(
await ipc.invoke('external-sessions:import', {
adapterId: 'codex',
sourceSessionId: 'source-1',
}),
{ ok: false, reason: 'source_unreadable' },
);
});

test('rethrows import failures that have no distinct renderer reason', async () => {
const ipc = ipcHarness();
registerRuntimeHostExternalSessionsIpc(
{
client: clientFixture({
importExternalSession: async () => {
// An unsupported adapter is a bad request, not a model or source
// problem — it must NOT be relabeled as `source_unreadable`; it falls
// through to the generic banner.
throw new RuntimeHostOperationError(
'external-session.import',
'invalid_request',
'External Session source is unsupported',
);
},
}),
emitSessionsChanged() {},
},
ipc,
);

await assert.rejects(
() =>
ipc.invoke('external-sessions:import', {
adapterId: 'codex',
sourceSessionId: 'source-1',
}),
/External Session source is unsupported/,
);
});

test('rejects malformed renderer requests before they reach the Host client', async () => {
let calls = 0;
const ipc = ipcHarness();
Expand Down
51 changes: 37 additions & 14 deletions apps/desktop/src/main/runtime-host-external-sessions-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ import {
decodeExternalSessionCatalogQueryInput,
decodeExternalSessionImportInput,
} from '@maka/runtime-host/protocol';
import type { ExternalSessionImportIpcResult } from '../preload/external-session-import-result.js';
import type {
ExternalSessionImportFailureReason,
ExternalSessionImportIpcResult,
} from '../preload/external-session-import-result.js';
import type { DesktopHostExternalSessionCatalogItem } from '../preload/external-session-catalog.js';
import {
handleReconnectableRead,
Expand Down Expand Up @@ -85,22 +88,42 @@ export function registerRuntimeHostExternalSessionsIpc(
} catch (error) {
if (
error instanceof RuntimeHostOperationError &&
error.operation === 'external-session.import' &&
error.code === 'commit_outcome_unknown'
error.operation === 'external-session.import'
) {
// "Unknown" means the task may well be in the catalog, so tell the
// shell to read it again. Without this, the only trace of a maybe-
// committed import is the banner on the page, and the page is gone the
// moment the user leaves Settings -- which is exactly when they come
// back and import the same conversation a second time. No id: the
// whole point is that we do not know which task, if any, landed.
deps.emitSessionsChanged('created');
return {
ok: false,
reason: 'commit_outcome_unknown',
} satisfies ExternalSessionImportIpcResult;
if (error.code === 'commit_outcome_unknown') {
// "Unknown" means the task may well be in the catalog, so tell the
// shell to read it again. Without this, the only trace of a maybe-
// committed import is the banner on the page, and the page is gone the
// moment the user leaves Settings -- which is exactly when they come
// back and import the same conversation a second time. No id: the
// whole point is that we do not know which task, if any, landed.
deps.emitSessionsChanged('created');
return {
ok: false,
reason: 'commit_outcome_unknown',
} satisfies ExternalSessionImportIpcResult;
}
const reason = classifyImportFailure(error);
if (reason !== undefined) {
return { ok: false, reason } satisfies ExternalSessionImportIpcResult;
}
}
throw error;
}
});
}

/**
* Turn the intact Host operation error into a typed reason the renderer can
* render distinctly. Done here, in Desktop Main, because Electron IPC drops the
* `code` before the renderer sees the error. The coordinator publishes dedicated
* stable codes for these cases, so this maps by code alone — no message text and
* no reuse of an overloaded code such as `invalid_request`.
*/
function classifyImportFailure(
error: RuntimeHostOperationError,
): Exclude<ExternalSessionImportFailureReason, 'commit_outcome_unknown'> | undefined {
if (error.code === 'model_unavailable') return 'no_model';
if (error.code === 'source_unreadable') return 'source_unreadable';
return undefined;
}
19 changes: 17 additions & 2 deletions apps/desktop/src/preload/external-session-import-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,22 @@

import type { SessionSummary } from '@maka/core/session';

/** Stable Desktop IPC result for the one import failure that must not be retried blindly. */
/**
* Why an import did not produce a task. Each maps to a specific, actionable
* banner in the import page. Carried as a typed result rather than a thrown
* error because Electron IPC strips the custom `code` off a `RuntimeHostOperationError`
* on its way to the renderer — the reason must be decided in Desktop Main, where
* the code is still intact, and handed across as data.
*/
export type ExternalSessionImportFailureReason =
/** Import ran but its outcome is unknown; the catalog must be re-read (not retried blindly). */
| 'commit_outcome_unknown'
/** No usable model connection to attach the imported task to — configure a model first. */
| 'no_model'
/** The source conversation could not be read or converted (e.g. too large or malformed). */
| 'source_unreadable';

/** Stable Desktop IPC result for the import failures the page renders distinctly. */
export type ExternalSessionImportIpcResult<T extends SessionSummary = SessionSummary> =
| { readonly ok: true; readonly session: T }
| { readonly ok: false; readonly reason: 'commit_outcome_unknown' };
| { readonly ok: false; readonly reason: ExternalSessionImportFailureReason };
10 changes: 10 additions & 0 deletions apps/desktop/src/renderer/locales/external-session-import-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ type ExternalSessionImportCopy = {
importInProgressDescription: (name: string) => string;
importFailedTitle: string;
importFailedFallback: string;
/** No usable model connection to attach the imported task to. */
importFailedNoModel: string;
/** The source conversation could not be read or converted (e.g. too large). */
importFailedSourceUnreadable: string;
importRecoveredTitle: string;
importRecoveredDescription: (name: string) => string;
importNotRecordedTitle: string;
Expand Down Expand Up @@ -133,6 +137,8 @@ const COPY = {
importInProgressDescription: (name) => `正在导入「${name}」,完成后会直接打开这个任务。`,
importFailedTitle: '导入失败',
importFailedFallback: '该对话无法转换或保存。请检查来源后重试。',
importFailedNoModel: '没有可用的模型连接,无法为导入的任务选择模型。请先在 设置 · 模型 中配置并启用一个模型后再导入。',
importFailedSourceUnreadable: '无法读取或转换该对话,它可能过大、已损坏或暂时无法读取。请检查来源后重试。',
importRecoveredTitle: '已确认导入',
importRecoveredDescription: (name) => `「${name}」导入的任务现已可用。`,
importNotRecordedTitle: '没有发现新任务',
Expand Down Expand Up @@ -186,6 +192,10 @@ const COPY = {
`Importing “${name}”. Maka opens the task as soon as it lands.`,
importFailedTitle: 'Import failed',
importFailedFallback: 'This conversation could not be converted or saved. Check the source and try again.',
importFailedNoModel:
'No usable model connection to attach the imported task to. Configure and enable a model in Settings · Models, then import again.',
importFailedSourceUnreadable:
'This conversation could not be read or converted — it may be too large, malformed, or temporarily unreadable. Check the source and try again.',
importRecoveredTitle: 'Import confirmed',
importRecoveredDescription: (name) =>
`The imported task is available now for “${name}”.`,
Expand Down
Loading