From 7580586ac6a64d45e8d7984ea1839f9e11f95818 Mon Sep 17 00:00:00 2001 From: jiachengzhen Date: Wed, 2 Sep 2026 20:06:03 +0800 Subject: [PATCH] fix(onboarding): preserve completion across BYOK switch Backport-of: e679cf9caff48c59f03bb5587d35152cde013988 --- .../repositories/bootstrap-repo.ts | 61 ++++++ App/backend/src/services/account-service.ts | 4 + App/backend/src/services/index.ts | 1 + .../services/tests/account-service.test.ts | 198 +++++++++++++++++- App/frontend/desktop/src/app.tsx | 3 +- App/frontend/desktop/src/app/routes.ts | 22 +- .../desktop/src/app/tests/routes.test.ts | 72 +++++++ .../src/app/tests/runtime-app-source.test.ts | 2 +- App/frontend/desktop/src/pages/pet-page.tsx | 12 +- .../desktop/src/pages/tests/pet-page.test.tsx | 35 ++++ 10 files changed, 397 insertions(+), 13 deletions(-) diff --git a/App/backend/src/infrastructure/app-state-store/repositories/bootstrap-repo.ts b/App/backend/src/infrastructure/app-state-store/repositories/bootstrap-repo.ts index 9f07b7799..f8b523852 100644 --- a/App/backend/src/infrastructure/app-state-store/repositories/bootstrap-repo.ts +++ b/App/backend/src/infrastructure/app-state-store/repositories/bootstrap-repo.ts @@ -86,6 +86,8 @@ export interface BootstrapRepository { setAvatarSkin(patch: AvatarSkinPatch): AppSettingsDto; getOnboardingState(): OnboardingStateDto; updateOnboarding(patch: PatchOnboardingInput): OnboardingStateDto; + /** Copies only the completed-guide invariants from the active account into the local BYOK scope. */ + preserveCompletedOnboardingForLocalByok(): boolean; getPrivacySettings(): PrivacySettingsDto; updatePrivacy(patch: PatchPrivacyInput): PrivacySettingsDto; getScanPreferences(): ScanPreferences; @@ -252,6 +254,65 @@ export function createBootstrapRepository(db: DatabaseSync): BootstrapRepository return this.getOnboardingState(); }, + preserveCompletedOnboardingForLocalByok() { + const sourceUuid = getActiveUuidWithDefaults(db); + if (!sourceUuid || sourceUuid === LOCAL_BYOK_ACCOUNT_UUID) { + return false; + } + + const source = getRequiredRow>( + db, + `SELECT + has_finished_guide, + has_accepted_terms, + accepted_terms_version, + completed_at + FROM account_onboarding_state + WHERE uuid = ?`, + [sourceUuid] + ); + if (!toBoolean(source.has_finished_guide)) { + return false; + } + + ensureLocalOnboardingDefaults(db); + const now = new Date().toISOString(); + // Scan permission belongs to the installation scope, while the account's + // improvement-program choice must never become BYOK consent. + const result = db.prepare( + `UPDATE account_onboarding_state + SET has_finished_guide = 1, + current_step = 'completed', + has_accepted_terms = CASE WHEN has_accepted_terms = 1 THEN 1 ELSE ? END, + accepted_terms_version = CASE + WHEN has_accepted_terms = 1 THEN COALESCE(accepted_terms_version, ?) + WHEN ? = 1 THEN ? + ELSE accepted_terms_version + END, + improvement_program = CASE + WHEN improvement_program = 'unset' THEN 'not_applicable' + ELSE improvement_program + END, + completed_at = COALESCE(completed_at, ?, ?), + updated_at = ? + WHERE uuid = ? + AND has_finished_guide = 0` + ).run( + source.has_accepted_terms, + source.accepted_terms_version, + source.has_accepted_terms, + source.accepted_terms_version, + source.completed_at, + now, + now, + LOCAL_BYOK_ACCOUNT_UUID + ); + return result.changes > 0; + }, + getPrivacySettings() { const uuid = resolvePrivacyUuidWithDefaults(db); diff --git a/App/backend/src/services/account-service.ts b/App/backend/src/services/account-service.ts index 553ae28f3..67092ae46 100644 --- a/App/backend/src/services/account-service.ts +++ b/App/backend/src/services/account-service.ts @@ -20,6 +20,7 @@ import type { AccountSessionProfileInput, AccountSessionRepository } from "../infrastructure/app-state-store/repositories/account-session-repo.js"; +import type { BootstrapRepository } from "../infrastructure/app-state-store/repositories/bootstrap-repo.js"; import type { MemmyConfigWriter, RuntimeProjectionResult } from "../infrastructure/memmy-config/index.js"; import type { MemoryClient } from "../adapters/outbound/memory-client/index.js"; import type { OkResponse } from "@memmy/local-api-contracts"; @@ -41,6 +42,8 @@ export interface CreateAccountServiceOptions { cloudClient: CloudClient; /** Account session repository. */ accountSessionRepository: AccountSessionRepository; + /** Bootstrap repository used to preserve machine-level onboarding across logout. */ + bootstrapRepository: Pick; /** Memmy config writer. */ memmyConfigWriter?: MemmyConfigWriter; /** Memory client. */ @@ -175,6 +178,7 @@ export function createAccountService(options: CreateAccountServiceOptions): Acco async logout() { const uuid = options.accountSessionRepository.getCloudUuid(); const session = options.accountSessionRepository.get(); + options.bootstrapRepository.preserveCompletedOnboardingForLocalByok(); if (uuid) { try { await options.cloudClient.logout({ uuid }); diff --git a/App/backend/src/services/index.ts b/App/backend/src/services/index.ts index bdd32dabe..89eb59e35 100644 --- a/App/backend/src/services/index.ts +++ b/App/backend/src/services/index.ts @@ -191,6 +191,7 @@ export function createBackendServices(options: CreateBackendServicesOptions): Ba account: createAccountService({ cloudClient: options.cloudClient, accountSessionRepository: options.appStateStore.repositories.accountSession, + bootstrapRepository: options.appStateStore.repositories.bootstrap, memmyConfigWriter: options.memmyConfigWriter, memoryClient: options.memoryClient, accountChannel: options.accountChannel diff --git a/App/backend/src/services/tests/account-service.test.ts b/App/backend/src/services/tests/account-service.test.ts index 60a78bd56..76d38026d 100644 --- a/App/backend/src/services/tests/account-service.test.ts +++ b/App/backend/src/services/tests/account-service.test.ts @@ -1,6 +1,31 @@ /** Account service tests. */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { createAccountService } from "../account-service.js"; +import { LOCAL_BYOK_ACCOUNT_UUID } from "../../infrastructure/app-state-store/account-context.js"; +import { createAppStateStore } from "../../infrastructure/app-state-store/index.js"; +import { INSTALLATION_SCAN_SCOPE_UUID } from "../../infrastructure/installation-scan-scope.js"; +import { + createAccountService as createAccountServiceImplementation, + type CreateAccountServiceOptions +} from "../account-service.js"; + +type TestAccountServiceOptions = Omit & { + bootstrapRepository?: CreateAccountServiceOptions["bootstrapRepository"]; +}; + +function createAccountService(options: TestAccountServiceOptions) { + const { bootstrapRepository, ...rest } = options; + return createAccountServiceImplementation({ + ...rest, + bootstrapRepository: bootstrapRepository ?? { + preserveCompletedOnboardingForLocalByok() { + return false; + } + } + }); +} describe("AccountService", () => { it("rejects verification channels that are not supported by the desktop package", async () => { @@ -579,6 +604,12 @@ describe("AccountService", () => { return true; } }, + bootstrapRepository: { + preserveCompletedOnboardingForLocalByok() { + calls.push("preserve-onboarding"); + return true; + } + }, memmyConfigWriter: { async writeAccountModelProjection() { calls.push("write-account"); @@ -606,12 +637,170 @@ describe("AccountService", () => { await expect(service.logout()).resolves.toEqual({ ok: true }); expect(calls).toEqual([ + "preserve-onboarding", "cloud-logout:cloud.login.uuid", "clear-account-config:true:cloud.login.uuid", "clear-if:cloud.login.uuid" ]); }); + it("preserves local onboarding before a failed cloud logout and still clears the local session", async () => { + const calls: string[] = []; + const service = createAccountService({ + cloudClient: { + ...createCloudClientStub(), + async logout() { + calls.push("cloud-logout"); + throw new Error("cloud unavailable"); + } + }, + accountSessionRepository: { + ...createAccountSessionRepositoryStub(), + getCloudUuid() { + return "cloud.login.uuid"; + }, + clearIfCloudUuid(cloudUuid) { + calls.push(`clear-if:${cloudUuid}`); + return true; + } + }, + bootstrapRepository: { + preserveCompletedOnboardingForLocalByok() { + calls.push("preserve-onboarding"); + return true; + } + } + }); + + await expect(service.logout()).resolves.toEqual({ ok: true }); + expect(calls).toEqual([ + "preserve-onboarding", + "cloud-logout", + "clear-if:cloud.login.uuid" + ]); + }); + + it("preserves completed onboarding in the local BYOK scope when logout clears the active account", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "memmy-account-logout-onboarding-")); + const databasePath = join(tempDir, "app.sqlite"); + let store: ReturnType | null = createAppStateStore({ databasePath }); + + try { + store.repositories.accountSession.upsert({ + profile: cloudProfile(), + uuid: "cloud-account-user-1", + cloudUuid: "cloud.login.uuid", + isNewUser: false, + authChannel: "email" + }); + store.repositories.bootstrap.updateOnboarding({ + completed: true, + currentStep: "completed", + hasAcceptedTerms: true, + acceptedTermsVersion: "2026-06-01", + scanPermission: "scan_only", + firstEncounterReportStatus: "shown", + improvementProgram: "accepted", + completedAt: "2026-06-20T12:00:00.000Z" + }); + const readInstallationOnboarding = () => store!.db.prepare( + `SELECT scan_permission, first_encounter_report_status, updated_at + FROM account_onboarding_state + WHERE uuid = ?` + ).get(INSTALLATION_SCAN_SCOPE_UUID); + const installationBeforeLogout = readInstallationOnboarding(); + + const service = createAccountService({ + cloudClient: createCloudClientStub(), + accountSessionRepository: store.repositories.accountSession, + bootstrapRepository: store.repositories.bootstrap + }); + + await expect(service.logout()).resolves.toEqual({ ok: true }); + expect(readInstallationOnboarding()).toEqual(installationBeforeLogout); + expect(store.repositories.accountSession.get()).toEqual({ authenticated: false }); + expect(store.repositories.bootstrap.getOnboardingState()).toMatchObject({ + completed: true, + currentStep: "completed", + hasAcceptedTerms: true, + acceptedTermsVersion: "2026-06-01", + scanPermission: "scan_only", + firstEncounterReportStatus: "shown", + improvementProgram: "not_applicable", + completedAt: "2026-06-20T12:00:00.000Z" + }); + + expect(store.repositories.accountSession.activateByCloudUuid("cloud.login.uuid", "email")).toBe(true); + expect(store.repositories.bootstrap.getOnboardingState()).toMatchObject({ + completed: true, + currentStep: "completed", + improvementProgram: "accepted", + completedAt: "2026-06-20T12:00:00.000Z" + }); + expect(store.repositories.bootstrap.preserveCompletedOnboardingForLocalByok()).toBe(false); + store.repositories.accountSession.clear(); + + store.repositories.bootstrap.updateAppSettings({ userMode: "byok" }); + store.close(); + store = null; + store = createAppStateStore({ databasePath }); + + expect(store.repositories.bootstrap.getAppSettings().userMode).toBe("byok"); + expect(store.repositories.bootstrap.getOnboardingState()).toMatchObject({ + completed: true, + currentStep: "completed", + hasAcceptedTerms: true, + acceptedTermsVersion: "2026-06-01", + scanPermission: "scan_only", + firstEncounterReportStatus: "shown", + improvementProgram: "not_applicable", + completedAt: "2026-06-20T12:00:00.000Z" + }); + } finally { + store?.close(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("does not promote incomplete account onboarding into the local BYOK scope", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "memmy-account-logout-onboarding-")); + const databasePath = join(tempDir, "app.sqlite"); + const store = createAppStateStore({ databasePath }); + + try { + store.repositories.accountSession.upsert({ + profile: cloudProfile(), + uuid: "cloud-account-user-1", + cloudUuid: "cloud.login.uuid", + isNewUser: false, + authChannel: "email" + }); + store.repositories.bootstrap.updateOnboarding({ + completed: false, + currentStep: "product_tour_required", + improvementProgram: "accepted" + }); + const readLocalByok = () => store.db.prepare( + `SELECT has_finished_guide, current_step, has_accepted_terms, + accepted_terms_version, improvement_program, completed_at, updated_at + FROM account_onboarding_state + WHERE uuid = ?` + ).get(LOCAL_BYOK_ACCOUNT_UUID); + const before = readLocalByok(); + const service = createAccountService({ + cloudClient: createCloudClientStub(), + accountSessionRepository: store.repositories.accountSession, + bootstrapRepository: store.repositories.bootstrap + }); + + await expect(service.logout()).resolves.toEqual({ ok: true }); + expect(readLocalByok()).toEqual(before); + } finally { + store.close(); + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("does not clear a newer account session when an older manual logout finishes late", async () => { const calls: string[] = []; let activeCloudUuid: string | null = "cloud.login.uuid"; @@ -639,6 +828,12 @@ describe("AccountService", () => { return true; } }, + bootstrapRepository: { + preserveCompletedOnboardingForLocalByok() { + calls.push("preserve-onboarding"); + return true; + } + }, memmyConfigWriter: { async writeAccountModelProjection() { return projectionResult(); @@ -667,6 +862,7 @@ describe("AccountService", () => { expect(activeCloudUuid).toBe("cloud.new.uuid"); expect(calls).toEqual([ + "preserve-onboarding", "cloud-logout", "clear-account-config:cloud.login.uuid", "clear-if:cloud.login.uuid" diff --git a/App/frontend/desktop/src/app.tsx b/App/frontend/desktop/src/app.tsx index bb0624101..51a6ec6bb 100644 --- a/App/frontend/desktop/src/app.tsx +++ b/App/frontend/desktop/src/app.tsx @@ -185,7 +185,8 @@ function RuntimeApp() { bootstrap: effectiveBootstrap, preferredMode: launchModeOverride ?? persistedPreferredMode, accountSession, - guidanceCompleted + guidanceCompleted, + modelConfig }); const initialPath = resolveLaunchInitialView({ defaultPath: defaultInitialPath, diff --git a/App/frontend/desktop/src/app/routes.ts b/App/frontend/desktop/src/app/routes.ts index 5b7327e45..d91a76676 100644 --- a/App/frontend/desktop/src/app/routes.ts +++ b/App/frontend/desktop/src/app/routes.ts @@ -63,6 +63,15 @@ export interface ResolveInitialViewInput { preferredMode: PreferredMode | null; accountSession?: AccountSessionView; guidanceCompleted?: boolean; + modelConfig?: ByokAgentModelAvailability | null; +} + +export interface ByokAgentModelAvailability { + catalog?: { + modelAssignments: { + byok: { agent: { candidates: readonly string[] } }; + }; + } | null; } /** Contract for pet launch guard input. */ @@ -134,6 +143,11 @@ export function resolveInitialView(input: ResolveInitialViewInput): AppRoutePath } if (input.bootstrap.app.userMode === "byok") { + if (input.modelConfig !== undefined && + !input.modelConfig?.catalog?.modelAssignments.byok.agent.candidates.length) { + return "/api-key"; + } + if (input.bootstrap.onboarding.completed) { return input.preferredMode === "pet" ? "/pet" : "/main"; } @@ -226,13 +240,7 @@ export function resolveByokModelCompletion(input: ResolveByokModelCompletionInpu /** Contract for resolve byok entry input. */ export interface ResolveByokEntryInput { onboarding: OnboardingStateDto | undefined; - modelConfig?: { - catalog?: { - modelAssignments: { - byok: { agent: { candidates: string[] } }; - }; - }; - } | null; + modelConfig?: ByokAgentModelAvailability | null; } /** Contract for resolve byok entry result. */ diff --git a/App/frontend/desktop/src/app/tests/routes.test.ts b/App/frontend/desktop/src/app/tests/routes.test.ts index 607ddfb83..6556f70d5 100644 --- a/App/frontend/desktop/src/app/tests/routes.test.ts +++ b/App/frontend/desktop/src/app/tests/routes.test.ts @@ -156,6 +156,78 @@ describe("desktop route table", () => { ).toBe("/api-key"); }); + it("keeps completed BYOK onboarding on API key setup until an Agent model is configured", () => { + const completedByokBootstrap = { + ...baseBootstrap, + app: { ...baseBootstrap.app, userMode: "byok" as const }, + onboarding: { + ...baseBootstrap.onboarding, + completed: true, + currentStep: "completed" as const, + firstEncounterReportStatus: "shown" as const, + completedAt: "2026-06-04T00:00:00.000Z" + } + }; + + expect(resolveInitialView({ + bootstrap: completedByokBootstrap, + preferredMode: "full", + modelConfig: { + catalog: { + modelAssignments: { + byok: { agent: { candidates: [] } } + } + } + } + })).toBe("/api-key"); + expect(resolveInitialView({ + bootstrap: completedByokBootstrap, + preferredMode: "full", + modelConfig: { + catalog: { + modelAssignments: { + byok: { agent: { candidates: ["local-agent"] } } + } + } + } + })).toBe("/main"); + }); + + it("keeps a pending BYOK first report after completion carryover once a model is configured", () => { + const carriedBootstrap = { + ...baseBootstrap, + app: { ...baseBootstrap.app, userMode: "byok" as const }, + onboarding: { + ...baseBootstrap.onboarding, + completed: true, + currentStep: "completed" as const, + firstEncounterReportStatus: "pending" as const, + completedAt: "2026-06-04T00:00:00.000Z" + } + }; + const reconciled = reconcileInitialOnboarding({ bootstrap: carriedBootstrap }); + + expect(reconciled.onboarding).toMatchObject({ + completed: false, + currentStep: "scan_permission_required", + firstEncounterReportStatus: "pending" + }); + expect(resolveInitialView({ + bootstrap: reconciled, + preferredMode: "full", + modelConfig: { + catalog: { modelAssignments: { byok: { agent: { candidates: [] } } } } + } + })).toBe("/api-key"); + expect(resolveInitialView({ + bootstrap: reconciled, + preferredMode: "full", + modelConfig: { + catalog: { modelAssignments: { byok: { agent: { candidates: ["local-agent"] } } } } + } + })).toBe("/onboarding"); + }); + it("respects the preferred full or pet mode after onboarding is complete", () => { const completedBootstrap = { ...baseBootstrap, diff --git a/App/frontend/desktop/src/app/tests/runtime-app-source.test.ts b/App/frontend/desktop/src/app/tests/runtime-app-source.test.ts index 2dfec764d..c2ae3249a 100644 --- a/App/frontend/desktop/src/app/tests/runtime-app-source.test.ts +++ b/App/frontend/desktop/src/app/tests/runtime-app-source.test.ts @@ -18,7 +18,7 @@ describe("RuntimeApp bootstrap loading", () => { const source = readFileSync(resolve(__dirname, "../..", "app.tsx"), "utf8"); expect(source).toContain("const guidanceCompleted = readGuidanceCompleted("); - expect(source).toContain("accountSession,\n guidanceCompleted"); + expect(source).toContain("accountSession,\n guidanceCompleted,\n modelConfig"); }); it("handles existing main-window route targets without reloading the renderer", () => { diff --git a/App/frontend/desktop/src/pages/pet-page.tsx b/App/frontend/desktop/src/pages/pet-page.tsx index 5bde88e86..effd4a1f9 100644 --- a/App/frontend/desktop/src/pages/pet-page.tsx +++ b/App/frontend/desktop/src/pages/pet-page.tsx @@ -2,7 +2,7 @@ import type { AccountSessionView } from "@memmy/local-api-contracts"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type RefObject } from "react"; import { useApiClients } from "../app/providers.js"; -import { FOCUSED_AGENT_CHAT_STORAGE_KEY, readGuidanceCompleted, resolveInitialView, type AppRoutePath } from "../app/routes.js"; +import { FOCUSED_AGENT_CHAT_STORAGE_KEY, readGuidanceCompleted, resolveInitialView, type AppRoutePath, type ByokAgentModelAvailability } from "../app/routes.js"; import type { MemmyAgentClient, MemmyAgentSessionSummary, MemmyAgentUnsubscribe, MemmyAgentWebSocketConnection, MemmyAgentWsEvent } from "../api/memmy-agent-client.js"; import type { AsrClient } from "../api/asr-client.js"; import { Memmy, type MemmyPose } from "../components/mascot/memmy.js"; @@ -258,7 +258,12 @@ export function resolvePetMainRouteSessionId(input: { explicitSessionId?: string } /** Handles resolve pet full route. */ -export function resolvePetFullRoute(input: Pick & { guidanceCompleted?: boolean }): AppRoutePath { +export function resolvePetFullRoute( + input: Pick & { + guidanceCompleted?: boolean; + modelConfig?: ByokAgentModelAvailability | null; + } +): AppRoutePath { if (!input.bootstrap) { return "/welcome"; } @@ -267,7 +272,8 @@ export function resolvePetFullRoute(input: Pick { })).toBe("/main"); }); + it("BYOK 尚无 Agent 模型时桌宠展开回到 API Key 配置", () => { + const bootstrap = { + ...mockBootstrap, + app: { ...mockBootstrap.app, userMode: "byok" as const }, + onboarding: { + ...mockBootstrap.onboarding, + completed: true, + currentStep: "completed" as const, + firstEncounterReportStatus: "shown" as const, + completedAt: "2026-06-01T00:00:00.000Z" + } + }; + const account = { + email: "", + phoneNumber: null, + nickname: "", + registeredAt: null + }; + + expect(resolvePetFullRoute({ + bootstrap, + account, + modelConfig: { + catalog: { modelAssignments: { byok: { agent: { candidates: [] } } } } + } + })).toBe("/api-key"); + expect(resolvePetFullRoute({ + bootstrap, + account, + modelConfig: { + catalog: { modelAssignments: { byok: { agent: { candidates: ["local-agent"] } } } } + } + })).toBe("/main"); + }); + it("录音计时格式化为 m:ss", () => { expect(formatRecordSeconds(0)).toBe("0:00"); expect(formatRecordSeconds(65)).toBe("1:05");