diff --git a/crates/agent-gui/src/App.tsx b/crates/agent-gui/src/App.tsx index 5b7ca597f..185590be7 100644 --- a/crates/agent-gui/src/App.tsx +++ b/crates/agent-gui/src/App.tsx @@ -40,6 +40,7 @@ import { WindowsSettingsHeader } from "./components/WindowsSettingsHeader"; import { WindowsTitleBar } from "./components/WindowsTitleBar"; import { useAppUpdateController } from "./lib/appUpdates"; import { isKBrainBrowserHost } from "./lib/host"; +import { HistoryMigrationNoticeBanner } from "./lib/kbrain/HistoryMigrationNoticeBanner"; import { requestPlanning } from "./lib/planning/kbrain"; import { TimeZoneNoticeBanner } from "./lib/planning/TimeZoneNoticeBanner"; import { setRetryErrorExtension } from "./lib/providers/runtime/streamRetry"; @@ -799,7 +800,14 @@ export default function App() { return ( - openSettings("system")} />}> + + openSettings("system")} /> + openSettings("system")} /> + + } + > {backgroundHostsReady ? ( diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index 03d8985df..f580c38fd 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -24,6 +24,22 @@ export const GUI_TRANSLATION_OVERRIDES: Record> = "historyImport.empty": "未发现可导入的旧对话;已明确删除的记录会跳过。", "historyImport.partial": "{count} 条对话正文已导入,但文件回滚记录尚不完整,可稍后重试。", "historyImport.failures": "查看失败详情(可再次点击导入重试)", + "historyImport.failedList": "{count} 条旧对话没能迁移", + "historyImport.reason.too_large": + "对话超过当前 K-brain 的单次导入大小限制。升级后端后,可点「导入旧版对话」重试。", + "historyImport.reason.conflict": "K-brain 里已有同一条对话,但内容不一样,K-brain 拒绝覆盖。", + "historyImport.reason.rejected": "K-brain 认为这条对话的数据无效,拒绝导入。", + "historyImport.reason.transient": "连接中断或后端出错,下次启动会自动重试。", + "historyImport.notice.title": "{count} 条旧对话没能迁移到 K-brain", + "historyImport.notice.description": + "这些对话在当前版本里看不到。旧数据没有删除,启动时也不会再反复上传。", + "historyImport.notice.view": "查看详情", + "historyImport.notice.dismiss": "关闭提示", + "historyImport.notice.dismissTitle": "关闭这条提示?", + "historyImport.notice.dismissDescription": + "关闭后不再提示这些对话的迁移失败,此操作不可撤销。设置里的失败清单仍会保留;对话内容变化后如果再次失败,会重新提示。", + "historyImport.notice.dismissConfirm": "关闭并不再提示", + "historyImport.notice.cancel": "取消", "app.windowPinned": "已置顶", "app.windowPinnedHint": "窗口已置顶,浮在其他应用上方。点击取消置顶。", "app.settingsLoadFailed": "加载设置失败,已回退到默认配置。", @@ -330,6 +346,24 @@ export const GUI_TRANSLATION_OVERRIDES: Record> = "historyImport.partial": "{count} conversation transcripts were imported, but file rollback records are incomplete. You can retry later.", "historyImport.failures": "View failures (click Import again to retry)", + "historyImport.failedList": "{count} old conversation(s) could not be migrated", + "historyImport.reason.too_large": + "The conversation exceeds the current K-brain import size limit. After upgrading the backend, retry with Import old conversations.", + "historyImport.reason.conflict": + "K-brain already holds this conversation with different content and refused to overwrite it.", + "historyImport.reason.rejected": "K-brain rejected this conversation's data as invalid.", + "historyImport.reason.transient": + "The connection dropped or the backend failed; it is retried on the next launch.", + "historyImport.notice.title": "{count} old conversation(s) were not migrated into K-brain", + "historyImport.notice.description": + "They are not visible in this version. The old data is kept and is no longer re-uploaded on every launch.", + "historyImport.notice.view": "View details", + "historyImport.notice.dismiss": "Dismiss", + "historyImport.notice.dismissTitle": "Dismiss this notice?", + "historyImport.notice.dismissDescription": + "These migration failures will no longer be announced. This cannot be undone. Settings keeps the list; a conversation that changes and fails again is announced anew.", + "historyImport.notice.dismissConfirm": "Dismiss permanently", + "historyImport.notice.cancel": "Cancel", "app.windowPinned": "Pinned", "app.windowPinnedHint": "Window is pinned above other apps. Click to unpin.", "app.settingsLoadFailed": "Failed to load settings. Default settings have been restored.", diff --git a/crates/agent-gui/src/lib/kbrain/HistoryMigrationNoticeBanner.tsx b/crates/agent-gui/src/lib/kbrain/HistoryMigrationNoticeBanner.tsx new file mode 100644 index 000000000..210ed1071 --- /dev/null +++ b/crates/agent-gui/src/lib/kbrain/HistoryMigrationNoticeBanner.tsx @@ -0,0 +1,66 @@ +import { X } from "@liveagent/ui/components/IconSet"; +import { SettingsNotice } from "@liveagent/ui/components/settings/SettingsNotice"; +import { Button } from "@liveagent/ui/components/ui/button"; +import { useConfirmDialog } from "@liveagent/ui/components/ui/confirm-dialog"; +import { useLocale } from "@liveagent/ui/i18n"; +import { useSyncExternalStore } from "react"; +import { + dismissHistoryMigrationFailures, + getHistoryMigrationFailures, + subscribeHistoryMigrationFailures, +} from "./historyMigrationFailures"; +import { kBrainStorageScope } from "./mapping"; + +/** App-level notice for legacy conversations that could not be migrated into K-brain. */ +export function HistoryMigrationNoticeBanner({ onOpenSettings }: { onOpenSettings: () => void }) { + const { t } = useLocale(); + const scope = kBrainStorageScope(); + const { undismissed } = useSyncExternalStore(subscribeHistoryMigrationFailures, () => + getHistoryMigrationFailures(scope), + ); + const { confirm, dialog } = useConfirmDialog(); + if (undismissed.length === 0) return dialog; + + async function dismiss() { + const confirmed = await confirm({ + title: t("historyImport.notice.dismissTitle"), + description: t("historyImport.notice.dismissDescription"), + confirmLabel: t("historyImport.notice.dismissConfirm"), + cancelLabel: t("historyImport.notice.cancel"), + preferCancel: true, + }); + if (confirmed) dismissHistoryMigrationFailures(scope, undismissed); + } + + return ( + <> + +
+

+ {t("historyImport.notice.title").replace("{count}", String(undismissed.length))} +

+

{t("historyImport.notice.description")}

+
+ + +
+ {dialog} + + ); +} diff --git a/crates/agent-gui/src/lib/kbrain/LegacyHistoryImportSettings.tsx b/crates/agent-gui/src/lib/kbrain/LegacyHistoryImportSettings.tsx index cc2668ef8..168daf06c 100644 --- a/crates/agent-gui/src/lib/kbrain/LegacyHistoryImportSettings.tsx +++ b/crates/agent-gui/src/lib/kbrain/LegacyHistoryImportSettings.tsx @@ -3,15 +3,53 @@ import { useLocale } from "@liveagent/ui/i18n"; import { SettingsGroup, SettingsRow } from "@liveagent/ui/pages/settings/shared"; import { useSyncExternalStore } from "react"; import { isTauriHost } from "../host"; +import { + getHistoryMigrationFailures, + type HistoryMigrationFailureRecord, + subscribeHistoryMigrationFailures, +} from "./historyMigrationFailures"; import { getHistoryImportState, importOldConversations, subscribeHistoryImport, } from "./manualHistoryImport"; +import { kBrainStorageScope } from "./mapping"; + +export function formatMigrationBytes(bytes: number | undefined) { + return bytes === undefined ? "" : `${(bytes / 1048576).toFixed(1)} MB`; +} + +export function HistoryMigrationFailureList({ + records, +}: { + records: readonly HistoryMigrationFailureRecord[]; +}) { + const { t } = useLocale(); + return ( +
    + {records.map((record) => ( +
  • + {record.title || record.sourceId} + · {record.sourceId.slice(0, 8)} + {record.bytes !== undefined && ( + · {formatMigrationBytes(record.bytes)} + )} +
    + {t(`historyImport.reason.${record.kind}`)} +
    +
  • + ))} +
+ ); +} export function LegacyHistoryImportSettings() { const { t } = useLocale(); const state = useSyncExternalStore(subscribeHistoryImport, getHistoryImportState); + const scope = kBrainStorageScope(); + const recorded = useSyncExternalStore(subscribeHistoryMigrationFailures, () => + getHistoryMigrationFailures(scope), + ); const desktop = isTauriHost(); const result = state.result; const imported = result?.results.filter((item) => item.status === "imported").length ?? 0; @@ -55,21 +93,24 @@ export function LegacyHistoryImportSettings() { {incomplete > 0 && (

{t("historyImport.partial").replace("{count}", String(incomplete))}

)} - {result.failures.length > 0 && ( -
- {t("historyImport.failures")} -
    - {result.failures.map((failure, index) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: a static report of one import run; rows never reorder -
  • - {failure.sourceId}: {failure.error} -
  • - ))} -
-
- )} + {result.failures + .filter((failure) => !failure.kind) + .map((failure) => ( + // Source-level paging errors have no conversation record to list. +

+ {failure.sourceId}: {failure.error} +

+ ))} )} + {recorded.all.length > 0 && ( +
+ + {t("historyImport.failedList").replace("{count}", String(recorded.all.length))} + + +
+ )} {state.error &&

{state.error}

} diff --git a/crates/agent-gui/src/lib/kbrain/historyMigration.ts b/crates/agent-gui/src/lib/kbrain/historyMigration.ts index 9b8156e61..7f1464b73 100644 --- a/crates/agent-gui/src/lib/kbrain/historyMigration.ts +++ b/crates/agent-gui/src/lib/kbrain/historyMigration.ts @@ -2,6 +2,15 @@ import type { Message } from "@liveagent/app/lib/agentTypes"; import { invoke } from "@liveagent/app/shims/tauriCore"; import { isTauriHost } from "../host"; import { createKBrainClient } from "./client"; +import { + classifyHistoryMigrationFailure, + type HistoryMigrationFailureKind, + type HistoryMigrationFailureRecord, + knownDeterministicFailure, + mergeHistoryMigrationFailures, + readHistoryMigrationFailures, + writeHistoryMigrationFailures, +} from "./historyMigrationFailures"; import { kBrainStorageScope, setKBrainSessionId } from "./mapping"; import { getConfiguredKBrainConnection } from "./runtimeConnection"; import { contextToKBrainMessages } from "./turn"; @@ -78,7 +87,26 @@ export type MigrationImportResult = { checkpoint: "available" | "partial" | "not_found" | "unresolved"; checkpoint_reason?: string; }; -export type MigrationFailure = { sourceId: string; error: string }; +export type MigrationFailure = { + sourceId: string; + error: string; + /** Present for per-conversation failures; absent for source-level paging errors. */ + kind?: HistoryMigrationFailureKind; + title?: string; + bytes?: number; + /** True when startup skipped it because the same content already failed deterministically. */ + skipped?: boolean; +}; + +export type HistoryMigrationOptions = { + fetch?: typeof globalThis.fetch; + signal?: AbortSignal; + /** + * Re-send conversations that already failed deterministically with unchanged content. + * Startup leaves this off; the manual "Import old conversations" action turns it on. + */ + retryKnownFailures?: boolean; +}; function modelFor(item: LegacyConversation): KBrainModelRef { try { @@ -309,7 +337,7 @@ export function historyErrorStatus(error: unknown): number | undefined { export async function migrateLegacyHistoryPage( page: LegacyPage, - options: { fetch?: typeof globalThis.fetch; signal?: AbortSignal } = {}, + options: HistoryMigrationOptions = {}, ): Promise<{ results: MigrationImportResult[]; failures: MigrationFailure[]; @@ -326,11 +354,34 @@ export async function migrateLegacyHistoryPage( const scope = kBrainStorageScope(); const cache = readMigrationCache(scope); let cacheChanged = false; + const knownFailures = readHistoryMigrationFailures(scope); + const failedRecords: HistoryMigrationFailureRecord[] = []; + const succeededIds: string[] = []; for (const item of page.conversations) { + let sourceFingerprint = ""; + let bodyBytes: number | undefined; + // Only the import POST speaks for the payload; earlier existence checks (GET) failing + // with 401/403/500 say nothing about whether this conversation can ever be imported. + let importAttempted = false; try { validateCheckpointExport(item.checkpoint); const messages = canonicalMessagesForMigration(item); - const sourceFingerprint = await fingerprint({ item, messages }); + sourceFingerprint = await fingerprint({ item, messages }); + const known = options.retryKnownFailures + ? undefined + : knownDeterministicFailure(knownFailures, item.id, sourceFingerprint); + if (known) { + // Same content failed the same way before; re-sending would only fail again. + failures.push({ + sourceId: item.id, + error: known.message, + kind: known.kind, + title: known.title, + bytes: known.bytes, + skipped: true, + }); + continue; + } const cached = cache[item.id]; if (cached?.fingerprint === sourceFingerprint && isStableMigrationResult(cached.result)) { let exists = true; @@ -398,6 +449,8 @@ export async function migrateLegacyHistoryPage( }, }, }; + bodyBytes = new TextEncoder().encode(JSON.stringify(payload)).byteLength; + importAttempted = true; const result = await client.importLegacyHistory(payload, options.signal); if ( result.source_id !== item.id || @@ -411,17 +464,53 @@ export async function migrateLegacyHistoryPage( cache[item.id] = { fingerprint: sourceFingerprint, result }; cacheChanged = true; } + succeededIds.push(item.id); results.push(result); } catch (error) { + const status = historyErrorStatus(error); // The backend persists deletion intent across restarts and clients. - if (historyErrorStatus(error) === 410) continue; + if (status === 410) { + succeededIds.push(item.id); + continue; + } + const message = error instanceof Error ? error.message : String(error); + const kind = importAttempted + ? classifyHistoryMigrationFailure({ + status: Number.isFinite(status) ? status : undefined, + message, + bytes: bodyBytes, + }) + : "transient"; failures.push({ sourceId: item.id, - error: error instanceof Error ? error.message : String(error), + error: message, + kind, + title: item.title, + bytes: bodyBytes, }); + if (sourceFingerprint) { + failedRecords.push({ + sourceId: item.id, + title: item.title, + fingerprint: sourceFingerprint, + kind, + bytes: bodyBytes, + message, + failedAt: Date.now(), + }); + } } } if (cacheChanged) writeMigrationCache(scope, cache); + if (failedRecords.length > 0 || succeededIds.length > 0) { + writeHistoryMigrationFailures( + scope, + mergeHistoryMigrationFailures(readHistoryMigrationFailures(scope), { + succeeded: succeededIds, + failed: failedRecords, + }), + ); + } return { results, failures, @@ -475,7 +564,7 @@ export async function recoverLegacyHistory(id: string): Promise { async function migrateHistorySourceOnce( command: "legacy_history_migration_page" | "pi_history_migration_page", - options: { fetch?: typeof globalThis.fetch; signal?: AbortSignal } = {}, + options: HistoryMigrationOptions = {}, ) { if (!isTauriHost() || !getConfiguredKBrainConnection()) { return { results: [], failures: [], complete: true }; @@ -510,21 +599,15 @@ async function migrateHistorySourceOnce( }; } -export async function migrateLegacyHistoryOnce( - options: { fetch?: typeof globalThis.fetch; signal?: AbortSignal } = {}, -) { +export async function migrateLegacyHistoryOnce(options: HistoryMigrationOptions = {}) { return migrateHistorySourceOnce("legacy_history_migration_page", options); } -export async function migratePiHistoryOnce( - options: { fetch?: typeof globalThis.fetch; signal?: AbortSignal } = {}, -) { +export async function migratePiHistoryOnce(options: HistoryMigrationOptions = {}) { return migrateHistorySourceOnce("pi_history_migration_page", options); } -export async function migrateAllHistoryOnce( - options: { fetch?: typeof globalThis.fetch; signal?: AbortSignal } = {}, -) { +export async function migrateAllHistoryOnce(options: HistoryMigrationOptions = {}) { const [legacy, pi] = await Promise.all([ migrateLegacyHistoryOnce(options), migratePiHistoryOnce(options), diff --git a/crates/agent-gui/src/lib/kbrain/historyMigrationFailures.ts b/crates/agent-gui/src/lib/kbrain/historyMigrationFailures.ts new file mode 100644 index 000000000..655926a5c --- /dev/null +++ b/crates/agent-gui/src/lib/kbrain/historyMigrationFailures.ts @@ -0,0 +1,215 @@ +/** + * Records legacy conversations that failed to migrate into K-brain, so that: + * + * - the user can see which conversations are missing and why (settings + app notice), + * - startup does not re-send a conversation that will deterministically fail again + * (oversized bodies are 5–30 MB each and used to be re-uploaded on every launch), + * - the manual "Import old conversations" action still retries everything. + * + * A record is keyed by conversation id and the migration source fingerprint: once the + * legacy conversation changes, the record no longer matches and startup retries it. + */ + +export type HistoryMigrationFailureKind = + /** Request body exceeds K-brain's limit; needs K-brain to accept larger imports. */ + | "too_large" + /** K-brain already holds this conversation with different content (HTTP 409). */ + | "conflict" + /** K-brain rejected the payload as invalid (other 4xx). */ + | "rejected" + /** Transport / server failure that may succeed on a later attempt. */ + | "transient"; + +export type HistoryMigrationFailureRecord = { + sourceId: string; + title: string; + fingerprint: string; + kind: HistoryMigrationFailureKind; + /** Serialized request body size when known. */ + bytes?: number; + message: string; + failedAt: number; +}; + +type Storage = Pick; + +function defaultStorage(): Storage | null { + try { + return globalThis.localStorage ?? null; + } catch { + return null; + } +} + +export function classifyHistoryMigrationFailure(input: { + status?: number; + message: string; + bytes?: number; +}): HistoryMigrationFailureKind { + const { status } = input; + if (status === 413) return "too_large"; + if (status === 409) return "conflict"; + if (status === 400 || status === 422) return "rejected"; + // Network errors and authentication/rate-limit failures can recover without payload changes. + return "transient"; +} + +/** Failures that will fail identically until the conversation or K-brain changes. */ +export function isDeterministicHistoryMigrationFailure(kind: HistoryMigrationFailureKind) { + return kind !== "transient"; +} + +function recordsKey(scope: string) { + return `liveagent.kbrain-history-migration-failures.v2:${scope}`; +} + +function dismissedKey(scope: string) { + return `liveagent.kbrain-history-migration-failures-dismissed.v1:${scope}`; +} + +function recordKey(record: Pick) { + return `${record.sourceId}:${record.fingerprint}`; +} + +export function readHistoryMigrationFailures( + scope: string, + storage: Storage | null = defaultStorage(), +): HistoryMigrationFailureRecord[] { + if (!storage) return []; + try { + const raw = storage.getItem(recordsKey(scope)); + const parsed = raw ? (JSON.parse(raw) as unknown) : null; + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (entry): entry is HistoryMigrationFailureRecord => + !!entry && + typeof entry.sourceId === "string" && + typeof entry.fingerprint === "string" && + typeof entry.kind === "string" && + typeof entry.message === "string", + ); + } catch { + return []; + } +} + +export function writeHistoryMigrationFailures( + scope: string, + records: readonly HistoryMigrationFailureRecord[], + storage: Storage | null = defaultStorage(), +): void { + if (storage) { + try { + storage.setItem(recordsKey(scope), JSON.stringify(records)); + } catch { + // Best effort: losing the record only costs one more attempt next launch. + } + } + publish(scope, storage); +} + +/** Record lookup used by migration to skip known deterministic failures at startup. */ +export function knownDeterministicFailure( + records: readonly HistoryMigrationFailureRecord[], + sourceId: string, + fingerprint: string, +): HistoryMigrationFailureRecord | undefined { + return records.find( + (record) => + record.sourceId === sourceId && + record.fingerprint === fingerprint && + isDeterministicHistoryMigrationFailure(record.kind), + ); +} + +/** + * Merges one migration pass into the stored records: successes drop their record, + * new failures replace any older record for the same conversation. + */ +export function mergeHistoryMigrationFailures( + previous: readonly HistoryMigrationFailureRecord[], + pass: { + succeeded: Iterable; + failed: readonly HistoryMigrationFailureRecord[]; + }, +): HistoryMigrationFailureRecord[] { + const succeeded = new Set(pass.succeeded); + const byId = new Map(); + for (const record of previous) { + if (!succeeded.has(record.sourceId)) byId.set(record.sourceId, record); + } + for (const record of pass.failed) byId.set(record.sourceId, record); + return [...byId.values()].sort((left, right) => left.sourceId.localeCompare(right.sourceId)); +} + +export function readDismissedHistoryMigrationFailures( + scope: string, + storage: Storage | null = defaultStorage(), +): Set { + if (!storage) return new Set(); + try { + const raw = storage.getItem(dismissedKey(scope)); + const parsed = raw ? (JSON.parse(raw) as unknown) : null; + return new Set(Array.isArray(parsed) ? parsed.filter((key) => typeof key === "string") : []); + } catch { + return new Set(); + } +} + +/** + * Permanently hides the app-level notice for these records. Irreversible by design; the + * settings page keeps listing them, and a changed conversation produces a new record. + */ +export function dismissHistoryMigrationFailures( + scope: string, + records: readonly Pick[], + storage: Storage | null = defaultStorage(), +): void { + const keys = readDismissedHistoryMigrationFailures(scope, storage); + for (const record of records) keys.add(recordKey(record)); + if (storage) { + try { + storage.setItem(dismissedKey(scope), JSON.stringify([...keys])); + } catch { + // Best effort; the in-memory snapshot below still hides the notice this session. + } + } + publish(scope, storage); +} + +export type HistoryMigrationFailureSnapshot = { + /** Every recorded failure, for the settings page. */ + all: HistoryMigrationFailureRecord[]; + /** Failures the user has not dismissed, for the app-level notice. */ + undismissed: HistoryMigrationFailureRecord[]; +}; + +const EMPTY: HistoryMigrationFailureSnapshot = { all: [], undismissed: [] }; +const snapshots = new Map(); +const listeners = new Set<() => void>(); + +function publish(scope: string, storage: Storage | null) { + const all = readHistoryMigrationFailures(scope, storage); + const dismissed = readDismissedHistoryMigrationFailures(scope, storage); + snapshots.set(scope, { + all, + undismissed: all.filter((record) => !dismissed.has(recordKey(record))), + }); + for (const listener of listeners) listener(); +} + +export function getHistoryMigrationFailures(scope: string): HistoryMigrationFailureSnapshot { + let snapshot = snapshots.get(scope); + if (!snapshot) { + publish(scope, defaultStorage()); + snapshot = snapshots.get(scope) ?? EMPTY; + } + return snapshot; +} + +export function subscribeHistoryMigrationFailures(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/crates/agent-gui/src/lib/kbrain/manualHistoryImport.ts b/crates/agent-gui/src/lib/kbrain/manualHistoryImport.ts index 7f27c1ebb..02517bc0e 100644 --- a/crates/agent-gui/src/lib/kbrain/manualHistoryImport.ts +++ b/crates/agent-gui/src/lib/kbrain/manualHistoryImport.ts @@ -36,7 +36,8 @@ export function importOldConversations(): Promise { update({ running: true }); pending = (async () => { try { - const result = await migrateAllHistoryOnce(); + // An explicit user action retries everything, including known oversized/conflicting ones. + const result = await migrateAllHistoryOnce({ retryKnownFailures: true }); update({ running: false, result }); if (result.results.length) for (const listener of importedListeners) listener(); } catch (error) { diff --git a/crates/agent-gui/src/main.tsx b/crates/agent-gui/src/main.tsx index fc8e07fc6..a2d2c8ffa 100644 --- a/crates/agent-gui/src/main.tsx +++ b/crates/agent-gui/src/main.tsx @@ -30,9 +30,12 @@ function renderBootstrapError(error: unknown, retry: () => void) { const bootstrap = createKBrainBootstrapRunner(async () => { try { await connectKBrainBackendWithRetry(); + // Startup skips conversations that already failed deterministically with unchanged + // content; they are listed in Settings and in the app notice instead of re-uploaded. const migration = await migrateAllHistoryOnce(); - if (migration.failures.length > 0) { - console.warn("Legacy history migration incomplete", migration.failures); + const newFailures = migration.failures.filter((failure) => !failure.skipped); + if (newFailures.length > 0) { + console.warn("Legacy history migration incomplete", newFailures); } const { default: App } = await import("./App"); root.render( diff --git a/crates/agent-gui/test/providers/kbrain-history-migration-failures.test.mjs b/crates/agent-gui/test/providers/kbrain-history-migration-failures.test.mjs new file mode 100644 index 000000000..bb9a170f8 --- /dev/null +++ b/crates/agent-gui/test/providers/kbrain-history-migration-failures.test.mjs @@ -0,0 +1,199 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader({ + mocks: { + "@liveagent/app/shims/tauriCore": { + invoke: async () => { + throw new Error("native invoke not expected"); + }, + }, + "../host": { isTauriHost: () => true }, + }, +}); +const failures = loader.loadModule("src/lib/kbrain/historyMigrationFailures.ts"); +const migration = loader.loadModule("src/lib/kbrain/historyMigration.ts"); +const runtime = loader.loadModule("src/lib/kbrain/runtimeConnection.ts"); + +function memoryStorage() { + const values = new Map(); + return { + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => values.set(key, String(value)), + }; +} + +function page(id = "big-one", text = "old question") { + return { + complete: true, + conversations: [ + { + id, + title: "A long conversation", + providerId: "removed", + model: "removed-model", + createdAt: 1000, + updatedAt: 2000, + isPinned: false, + isShared: false, + redactToolContent: false, + contextMetaJson: "{}", + checkpoint: { status: "not_found" }, + segments: [ + { + segmentIndex: 0, + segmentId: "first", + messagesJson: JSON.stringify([{ id: "u1", role: "user", content: text, timestamp: 1000 }]), + }, + ], + }, + ], + }; +} + +test("classification separates deterministic failures from transient ones", () => { + const limit = 4 << 20; + const classify = failures.classifyHistoryMigrationFailure; + assert.equal(classify({ message: "Failed to fetch", bytes: limit + 1 }), "transient"); + assert.equal(classify({ message: "Failed to fetch", bytes: limit - 1 }), "transient"); + assert.equal(classify({ message: "Failed to fetch" }), "transient"); + assert.equal(classify({ status: 413, message: "too big" }), "too_large"); + assert.equal(classify({ status: 409, message: "conflict" }), "conflict"); + assert.equal(classify({ status: 400, message: "bad" }), "rejected"); + assert.equal(classify({ status: 500, message: "boom" }), "transient"); + assert.equal(failures.isDeterministicHistoryMigrationFailure("transient"), false); +}); + +test("merge drops records for conversations that now succeed", () => { + const record = (sourceId, kind = "too_large") => ({ + sourceId, + title: sourceId, + fingerprint: "f", + kind, + message: "m", + failedAt: 1, + }); + const merged = failures.mergeHistoryMigrationFailures([record("a"), record("b")], { + succeeded: ["a"], + failed: [record("c", "conflict")], + }); + assert.deepEqual( + merged.map((entry) => entry.sourceId), + ["b", "c"], + ); +}); + +test("dismissal hides the notice but keeps the settings list", () => { + const storage = memoryStorage(); + const scope = "dismiss-scope"; + const record = { sourceId: "x", title: "X", fingerprint: "f1", kind: "too_large", message: "m", failedAt: 1 }; + failures.writeHistoryMigrationFailures(scope, [record], storage); + failures.dismissHistoryMigrationFailures(scope, [record], storage); + const dismissed = failures.readDismissedHistoryMigrationFailures(scope, storage); + assert.equal(dismissed.has("x:f1"), true); + assert.equal(failures.readHistoryMigrationFailures(scope, storage).length, 1); +}); + +test("startup skips a conversation that already failed for size; manual import retries it", async () => { + globalThis.localStorage = memoryStorage(); + runtime.setKBrainRuntimeConnection({ + baseUrl: "http://migration-failures.test", + token: "", + protocolVersion: "kbrain.agent.v1", + }); + // A payload supported by newer backends but rejected by older backends. + const bigPage = () => page("big-one", "x".repeat(4 << 20)); + let posts = 0; + let accept = false; + const fetch = async (_url, init) => { + if (init.method === "POST") { + posts++; + if (!accept) return new Response(JSON.stringify({ error: "import too large" }), { status: 413 }); + return new Response( + JSON.stringify({ + source_id: "big-one", + backend_id: "big-one", + status: "imported", + checkpoint: "not_found", + fingerprint: "fp", + }), + { status: 200 }, + ); + } + return new Response(JSON.stringify({ error: "not found" }), { status: 404 }); + }; + try { + const first = await migration.migrateLegacyHistoryPage(bigPage(), { fetch }); + assert.equal(posts, 1); + assert.equal(first.failures[0].kind, "too_large"); + assert.ok(first.failures[0].bytes > (4 << 20)); + + // Next launch: same content, no re-upload, but still reported (as skipped). + const second = await migration.migrateLegacyHistoryPage(bigPage(), { fetch }); + assert.equal(posts, 1); + assert.equal(second.failures.length, 1); + assert.equal(second.failures[0].skipped, true); + assert.equal(second.complete, false); + + // Manual import retries; once K-brain accepts it, the record is cleared. + accept = true; + const manual = await migration.migrateLegacyHistoryPage(bigPage(), { + fetch, + retryKnownFailures: true, + }); + assert.equal(posts, 2); + assert.equal(manual.failures.length, 0); + const scope = loader.loadModule("src/lib/kbrain/mapping.ts").kBrainStorageScope(); + assert.equal(failures.readHistoryMigrationFailures(scope).length, 0); + } finally { + runtime.clearKBrainRuntimeConnection(); + } +}); + +test("a transient failure is retried on the next launch", async () => { + globalThis.localStorage = memoryStorage(); + runtime.setKBrainRuntimeConnection({ + baseUrl: "http://migration-transient.test", + token: "", + protocolVersion: "kbrain.agent.v1", + }); + let posts = 0; + const fetch = async (_url, init) => { + if (init.method === "POST") { + posts++; + throw new TypeError("Failed to fetch"); + } + return new Response("{}", { status: 404 }); + }; + try { + await migration.migrateLegacyHistoryPage(page("large-transient", "x".repeat(5 << 20)), { fetch }); + await migration.migrateLegacyHistoryPage(page("large-transient", "x".repeat(5 << 20)), { fetch }); + assert.equal(posts, 2); + } finally { + runtime.clearKBrainRuntimeConnection(); + } +}); + +test("only the import POST can produce a deterministic failure record", async () => { + const { readFileSync } = await import("node:fs"); + const source = readFileSync( + new URL("../../src/lib/kbrain/historyMigration.ts", import.meta.url), + "utf8", + ); + assert.match(source, /importAttempted = true;\s+const result = await client\.importLegacyHistory/); + assert.match(source, /const kind = importAttempted\s+\? classifyHistoryMigrationFailure/); +}); + + test("recoverable HTTP failures are not cached as permanent rejection", () => { + for (const status of [401, 403, 404, 408, 425, 429, 502, 503]) { + assert.equal(failures.classifyHistoryMigrationFailure({ status, message: "retry", bytes: 5 << 20 }), "transient"); + } + }); + test("old size-inferred failures do not suppress migration after upgrade", () => { + const storage = memoryStorage(); + storage.setItem("liveagent.kbrain-history-migration-failures.v1:upgrade", JSON.stringify([ + { sourceId: "old", fingerprint: "f", kind: "too_large", message: "Failed to fetch" }, + ])); + assert.deepEqual(failures.readHistoryMigrationFailures("upgrade", storage), []); + });