vi.mock("./ai-consent", () => ({ promptAiConsent: vi.fn() })); import * as SecureStore from "expo-secure-store"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { promptAiConsent } from "./ai-consent"; import type { MobileMessage, MobileSnapshot } from "./api.js"; import { adoptDeletedSpaceFallback, applyMobileThreadEvent, authHeaders, blockText, changePassword, currentApiBase, deleteAccount, loadApiBase, MAX_MOBILE_AUTH_RESPONSE_BYTES, MAX_MOBILE_RPC_RESPONSE_BYTES, mergeMobileSnapshot, messageAttachments, passwordResetCapabilities, prependMobileMessagePage, requestPasswordReset, resetApiBase, rpc, saveApiBase, selectedSpaceId, selectInitialSpace, selectSpace, shouldApplyMobileThreadRefresh, signIn, signOut, signUp, subscribeThread, } from "./api.js"; import { resumeLiveNotifications } from "./live-notifications.js"; import { clearSessionToken, restoreSessionToken, saveSessionToken, snapshotSessionToken, } from "./session.js"; vi.mock("expo-secure-store", () => ({ getItemAsync: vi.fn(), setItemAsync: vi.fn(), deleteItemAsync: vi.fn(), })); vi.mock("./live-notifications.js", () => ({ resumeLiveNotifications: vi.fn(async () => undefined), stopLiveNotifications: vi.fn(async () => undefined), })); afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); describe("mobile API authentication", () => { beforeEach(async () => { vi.restoreAllMocks(); vi.mocked(SecureStore.getItemAsync).mockReset(); vi.mocked(SecureStore.setItemAsync).mockReset(); vi.mocked(SecureStore.deleteItemAsync).mockReset(); vi.mocked(resumeLiveNotifications).mockClear(); await restoreSessionToken(""); }); it("persists a successful sign-in token and sends the native origin", async () => { const fetchMock = vi.fn(async () => jsonResponse({ token: "session-token" })); vi.stubGlobal("fetch", fetchMock); await signIn("ada@example.com", "correct horse"); expect(fetchMock).toHaveBeenCalledWith( "http://127.0.0.1:3100/api/auth/sign-in/email", expect.objectContaining({ method: "POST", headers: { "content-type": "application/json", origin: "rakazo://" }, body: JSON.stringify({ email: "ada@example.com", password: "correct horse" }), }), ); expect(SecureStore.setItemAsync).toHaveBeenCalledWith("rakazo.session_token", "session-token"); expect(resumeLiveNotifications).not.toHaveBeenCalled(); }); it("creates an account and persists its session token", async () => { const fetchMock = vi.fn(async () => jsonResponse({ token: "signup-token" })); vi.stubGlobal("fetch", fetchMock); await signUp("new@example.com", "correct horse", "New User"); expect(fetchMock).toHaveBeenCalledWith( "http://127.0.0.1:3100/api/auth/sign-up/email", expect.objectContaining({ method: "POST", headers: { "content-type": "application/json", origin: "rakazo://" }, body: JSON.stringify({ email: "new@example.com", password: "correct horse", name: "New User", }), }), ); expect(SecureStore.setItemAsync).toHaveBeenCalledWith("rakazo.session_token", "signup-token"); }); it("loads password recovery capability and requests a server-approved redirect", async () => { const fetchMock = vi .fn() .mockResolvedValueOnce( jsonResponse({ passwordReset: true, resetUrl: "https://rakazo.test/reset-password" }), ) .mockResolvedValueOnce(jsonResponse({ status: true })); vi.stubGlobal("fetch", fetchMock); await expect(passwordResetCapabilities()).resolves.toEqual({ passwordReset: true, resetUrl: "https://rakazo.test/reset-password", }); await requestPasswordReset("ada@example.test", "https://rakazo.test/reset-password"); expect(fetchMock).toHaveBeenNthCalledWith( 2, "http://127.0.0.1:3100/api/auth/request-password-reset", expect.objectContaining({ method: "POST", body: JSON.stringify({ email: "ada@example.test", redirectTo: "https://rakazo.test/reset-password", }), }), ); }); it("treats a malformed capabilities response as password recovery being unavailable", async () => { vi.stubGlobal( "fetch", vi.fn(async () => new Response("not-json", { status: 200 })), ); await expect(passwordResetCapabilities()).resolves.toEqual({ passwordReset: false, resetUrl: null, }); }); it("changes a password with the bearer session and revokes other sessions", async () => { vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token"); const fetchMock = vi.fn(async () => jsonResponse({ status: true })); vi.stubGlobal("fetch", fetchMock); await changePassword("old-password", "new-password"); expect(fetchMock).toHaveBeenCalledWith( "http://127.0.0.1:3100/api/auth/change-password", expect.objectContaining({ headers: expect.objectContaining({ authorization: "Bearer session-token" }), body: JSON.stringify({ currentPassword: "old-password", newPassword: "new-password", revokeOtherSessions: true, }), }), ); }); it("does not send a password or bearer token to a persisted public HTTP server", async () => { vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => { if (key === "rakazo.api_base") return "http://app.example.test"; if (key === "rakazo.session_token") return "session-token"; return null; }); const fetchMock = vi.fn(async () => jsonResponse({ status: true })); vi.stubGlobal("fetch", fetchMock); await loadApiBase(); await changePassword("old-password", "new-password"); expect(fetchMock).toHaveBeenCalledWith( "http://127.0.0.1:3100/api/auth/change-password", expect.objectContaining({ headers: expect.objectContaining({ authorization: "Bearer session-token" }), }), ); expect(fetchMock).not.toHaveBeenCalledWith( expect.stringMatching(/^http:\/\/app\.example\.test/), expect.anything(), ); }); it("starts notifications only after the inbox selects the default space", async () => { vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => key === "rakazo.session_token" ? "session-token" : null, ); await expect(selectInitialSpace("space-default")).resolves.toBe(true); expect(selectedSpaceId()).toBe("space-default"); expect(resumeLiveNotifications).toHaveBeenCalledWith( "http://127.0.0.1:3100", "session-token", "space-default", ); }); it("surfaces the server message and does not persist a failed sign-in", async () => { vi.stubGlobal( "fetch", vi.fn(async () => jsonResponse({ message: "Invalid credentials" }, { status: 401 })), ); await expect(signIn("ada@example.com", "wrong")).rejects.toThrow("Invalid credentials"); expect(SecureStore.setItemAsync).not.toHaveBeenCalled(); }); it("does not retain an oversized sign-in response", async () => { vi.stubGlobal( "fetch", vi.fn(async () => jsonResponse( { token: "must-not-be-read" }, { headers: { "content-length": String(MAX_MOBILE_AUTH_RESPONSE_BYTES + 1) } }, ), ), ); await expect(signIn("ada@example.com", "correct horse")).rejects.toThrow( `exceeds ${MAX_MOBILE_AUTH_RESPONSE_BYTES} bytes`, ); expect(SecureStore.setItemAsync).not.toHaveBeenCalled(); }); it("times out and cancels a stalled sign-in response body", async () => { vi.useFakeTimers(); const cancel = vi.fn(); vi.stubGlobal( "fetch", vi.fn(async () => new Response(new ReadableStream({ cancel }))), ); const pending = signIn("ada@example.com", "correct horse"); const rejection = expect(pending).rejects.toThrow("Request timed out"); await vi.advanceTimersByTimeAsync(8_000); await rejection; expect(cancel).toHaveBeenCalledOnce(); expect(SecureStore.setItemAsync).not.toHaveBeenCalled(); }); it("reports an rpc that hit its timeout as a timeout, not as a canceled fetch", async () => { vi.useFakeTimers(); vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token"); vi.stubGlobal( "fetch", vi.fn( (_input: unknown, init?: { signal?: AbortSignal }) => new Promise((_resolve, reject) => { init?.signal?.addEventListener("abort", () => reject(new Error("fetch failed: FetchRequestCanceledException")), ); }), ), ); const pending = rpc("computer/status", { botId: "bot" }); const rejection = expect(pending).rejects.toThrow("Request timed out"); await vi.advanceTimersByTimeAsync(8_000); await rejection; }); it("reports a stalled rpc response body as a timeout", async () => { vi.useFakeTimers(); vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token"); const cancel = vi.fn(); vi.stubGlobal( "fetch", vi.fn(async () => new Response(new ReadableStream({ cancel }))), ); const pending = rpc("computer/status", { botId: "bot" }); const rejection = expect(pending).rejects.toThrow("Request timed out"); await vi.advanceTimersByTimeAsync(8_000); await rejection; expect(cancel).toHaveBeenCalledOnce(); }); it("reports a caller's cancellation with the caller's reason", async () => { vi.useFakeTimers(); vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token"); vi.stubGlobal( "fetch", vi.fn( (_input: unknown, init?: { signal?: AbortSignal }) => new Promise((_resolve, reject) => { init?.signal?.addEventListener("abort", () => reject(new Error("fetch failed: FetchRequestCanceledException")), ); }), ), ); const external = new AbortController(); const pending = rpc("computer/status", { botId: "bot" }, { signal: external.signal }); const rejection = expect(pending).rejects.toThrow("screen closed"); await vi.advanceTimersByTimeAsync(0); external.abort(new Error("screen closed")); await rejection; }); it("lets a call opt into a longer timeout", async () => { vi.useFakeTimers(); vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token"); const fetchMock = vi.fn( (_input: unknown, init?: { signal?: AbortSignal }) => new Promise((resolve, reject) => { init?.signal?.addEventListener("abort", () => reject(new Error("aborted"))); setTimeout(() => resolve(new Response(JSON.stringify({ json: { ok: true } }))), 20_000); }), ); vi.stubGlobal("fetch", fetchMock); const pending = rpc<{ ok: boolean }>("computer/boot", { botId: "bot" }, { timeoutMs: 120_000 }); await vi.advanceTimersByTimeAsync(20_000); await expect(pending).resolves.toEqual({ ok: true }); }); it("clears the local session even when the sign-out request fails", async () => { vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token"); vi.stubGlobal( "fetch", vi.fn(async () => Promise.reject(new Error("offline"))), ); await expect(signOut()).resolves.toBeUndefined(); expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("rakazo.session_token"); }); it("unregisters push delivery before invalidating the session", async () => { vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token"); const fetchMock = vi .fn() .mockResolvedValueOnce(jsonResponse({ json: null })) .mockResolvedValueOnce(jsonResponse({})); vi.stubGlobal("fetch", fetchMock); await signOut(); expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ "http://127.0.0.1:3100/rpc/notifications/unregisterPush", "http://127.0.0.1:3100/api/auth/sign-out", ]); }); it("continues sign-out when push unregistration times out", async () => { vi.useFakeTimers(); vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token"); const fetchMock = vi .fn() .mockImplementationOnce( (_url, options: RequestInit) => new Promise((_resolve, reject) => { options.signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true, }); }), ) .mockResolvedValueOnce(jsonResponse({})); vi.stubGlobal("fetch", fetchMock); const pending = signOut(); await vi.advanceTimersByTimeAsync(8_000); await pending; expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ "http://127.0.0.1:3100/rpc/notifications/unregisterPush", "http://127.0.0.1:3100/api/auth/sign-out", ]); expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("rakazo.session_token"); }); it("clears the local session when the sign-out request stalls", async () => { vi.useFakeTimers(); vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token"); const fetchMock = vi .fn() .mockResolvedValueOnce(jsonResponse({ json: null })) .mockImplementationOnce(() => new Promise(() => undefined)); vi.stubGlobal("fetch", fetchMock); const pending = signOut(); await vi.advanceTimersByTimeAsync(8_000); await pending; expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("rakazo.session_token"); expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("rakazo.space_id"); }); it("unregisters push delivery before deleting the account", async () => { vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token"); const fetchMock = vi .fn() .mockResolvedValueOnce(jsonResponse({ json: null })) .mockResolvedValueOnce(jsonResponse({})); vi.stubGlobal("fetch", fetchMock); await deleteAccount("correct horse"); expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ "http://127.0.0.1:3100/rpc/notifications/unregisterPush", "http://127.0.0.1:3100/api/auth/delete-user", ]); }); it("sends authenticated RPC input and reports structured RPC errors", async () => { vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token"); const fetchMock = vi .fn() .mockResolvedValueOnce(jsonResponse({ json: { ok: true } })) .mockResolvedValueOnce( jsonResponse({ error: { message: "Bot does not exist" } }, { status: 404 }), ); vi.stubGlobal("fetch", fetchMock); await expect(rpc<{ ok: boolean }>("bots/get", { botId: "bot-1" })).resolves.toEqual({ ok: true, }); expect(fetchMock).toHaveBeenNthCalledWith( 1, "http://127.0.0.1:3100/rpc/bots/get", expect.objectContaining({ headers: expect.objectContaining({ authorization: "Bearer session-token" }), body: JSON.stringify({ json: { botId: "bot-1" } }), }), ); await expect(rpc("bots/get", { botId: "missing" })).rejects.toThrow("Bot does not exist"); }); it("blocks mobile message and attachment submission when AI sharing is declined", async () => { vi.mocked(promptAiConsent).mockResolvedValue(false); const fetchMock = vi.fn(async (_url: string | URL | Request, _init?: RequestInit) => jsonResponse({ json: { scope: "account-space", version: "2026-09-14", recipients: [ { key: "provider", name: "Example AI", use: "model", detail: "", allowed: false }, ], }, }), ); vi.stubGlobal("fetch", fetchMock); for (const proc of ["threads/send", "artifacts/create", "routines/create"]) { await expect(rpc(proc, { botId: "bot-1", text: "private content" })).rejects.toThrow( "AI data sharing", ); } expect(fetchMock).toHaveBeenCalledTimes(3); for (const [url, init] of fetchMock.mock.calls) { expect(String(url)).toContain("/rpc/aiConsent/status"); expect(init?.body).not.toContain("private content"); } }); it.each([true, false])("explains the mobile upgrade requirement with JSON=%s", async (json) => { const fetchMock = vi.fn(async () => json ? jsonResponse({ error: { message: "Not found" } }, { status: 404 }) : new Response("404 Not Found", { status: 404 }), ); vi.stubGlobal("fetch", fetchMock); await expect(rpc("threads/send", { botId: "bot" })).rejects.toThrow("Update your server"); expect(fetchMock).toHaveBeenCalledOnce(); }); it.each(["throws", "rejects", "stalls"])( "reports the upgrade message when body cancellation %s", async (mode) => { const cancel = vi.fn(() => { if (mode === "throws") throw new Error("cancel failed"); if (mode === "rejects") return Promise.reject(new Error("cancel failed")); return new Promise(() => {}); }); vi.stubGlobal( "fetch", vi.fn(async () => ({ status: 404, body: { cancel } })), ); await expect(rpc("threads/send", { botId: "bot" })).rejects.toThrow("Update your server"); expect(cancel).toHaveBeenCalledOnce(); }, ); it("records mobile consent before submitting and uses the deployment policy URL", async () => { vi.mocked(promptAiConsent).mockResolvedValue(true); const calls: string[] = []; const recipient = { key: "provider", name: "Example AI", use: "model", detail: "", allowed: false, }; vi.stubGlobal( "fetch", vi.fn(async (url) => { const path = new URL(String(url)).pathname; calls.push(path); return jsonResponse({ json: path.endsWith("/status") ? { scope: "account-space", version: "2026-09-14", recipients: [recipient], privacyUrl: "https://example.com/privacy", } : { ok: true }, }); }), ); await rpc("threads/send", { botId: "bot-1", text: "authorized content" }); expect(calls).toEqual(["/rpc/aiConsent/status", "/rpc/aiConsent/allow", "/rpc/threads/send"]); expect(promptAiConsent).toHaveBeenLastCalledWith(recipient, "https://example.com/privacy"); }); it("rejects an oversized RPC response before parsing it", async () => { vi.stubGlobal( "fetch", vi.fn(async () => jsonResponse( { json: { ok: true } }, { headers: { "content-length": String(MAX_MOBILE_RPC_RESPONSE_BYTES + 1) } }, ), ), ); await expect(rpc("bots/get")).rejects.toThrow(`exceeds ${MAX_MOBILE_RPC_RESPONSE_BYTES} bytes`); }); it("shares the selected space with direct API requests", async () => { vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token"); await selectSpace("space-support"); await expect(authHeaders()).resolves.toEqual({ authorization: "Bearer session-token", "x-rakazo-space-id": "space-support", }); }); it("does not switch spaces when the selection cannot be persisted", async () => { await expect(selectSpace("space-support")).resolves.toBe(true); vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key) => { if (key === "rakazo.space_id") throw new Error("device locked"); }); await expect(selectSpace("space-social")).resolves.toBe(false); expect(selectedSpaceId()).toBe("space-support"); vi.mocked(SecureStore.setItemAsync).mockReset(); await selectSpace(""); }); it("does not switch spaces when stale recovery cannot be cleared", async () => { await selectSpace("space-support"); vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { if (key === "rakazo.space_rollback") throw new Error("device locked"); }); vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { if (key === "rakazo.space_rollback" && value === "") throw new Error("device locked"); }); await expect(selectSpace("space-social")).resolves.toBe(false); expect(SecureStore.setItemAsync).not.toHaveBeenCalledWith("rakazo.space_id", "space-social"); expect(selectedSpaceId()).toBe("space-support"); vi.mocked(SecureStore.setItemAsync).mockReset(); vi.mocked(SecureStore.deleteItemAsync).mockReset(); await selectSpace(""); }); it("refuses sign-in when a previous space cannot be cleared", async () => { await selectSpace("space-support"); vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { if (key === "rakazo.space_rollback") throw new Error("device locked"); }); vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { if (key === "rakazo.space_rollback" && value === "") throw new Error("device locked"); }); vi.stubGlobal( "fetch", vi.fn(async () => jsonResponse({ token: "new-session-token" })), ); await expect(signIn("ada@example.com", "correct horse")).rejects.toThrow( "Could not clear the previous space", ); expect(SecureStore.setItemAsync).not.toHaveBeenCalledWith( "rakazo.session_token", "new-session-token", ); expect(selectedSpaceId()).toBe("space-support"); vi.mocked(SecureStore.setItemAsync).mockReset(); vi.mocked(SecureStore.deleteItemAsync).mockReset(); await selectSpace(""); }); it("clears server-specific session and space state when the API endpoint changes", async () => { await selectSpace("space-support"); vi.mocked(SecureStore.deleteItemAsync).mockClear(); await expect(saveApiBase("https://second-server.example")).resolves.toMatchObject({ ok: true }); expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("rakazo.session_token"); expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("rakazo.space_id"); await resetApiBase(); }); it("refuses to switch endpoints when SecureStore cannot clear credentials", async () => { await selectSpace("space-support"); vi.mocked(SecureStore.deleteItemAsync).mockRejectedValue(new Error("device locked")); vi.mocked(SecureStore.setItemAsync).mockRejectedValue(new Error("device locked")); await expect(saveApiBase("https://second-server.example")).resolves.toEqual({ ok: false, error: "Could not clear the previous server session", }); expect(SecureStore.setItemAsync).not.toHaveBeenCalledWith( "rakazo.api_base", "https://second-server.example", ); }); it("restores notifications to the selected space when endpoint rollback succeeds", async () => { const previousApiBase = currentApiBase(); vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => { if (key === "rakazo.session_token") return "session-token"; return null; }); await selectSpace("space-social"); await selectSpace("space-support"); vi.mocked(resumeLiveNotifications).mockClear(); vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { if (key === "rakazo.space_id") throw new Error("device locked"); }); vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { if (key === "rakazo.session_token" || (key === "rakazo.space_id" && value === "")) { throw new Error("device locked"); } }); await expect(saveApiBase("https://second-server.example")).resolves.toEqual({ ok: false, error: "Could not clear the previous server session", }); await expect(authHeaders()).resolves.toEqual({ authorization: "Bearer session-token", "x-rakazo-space-id": "space-support", }); expect(SecureStore.setItemAsync).not.toHaveBeenCalledWith( "rakazo.api_base", "https://second-server.example", ); expect(resumeLiveNotifications).toHaveBeenCalledWith( previousApiBase, "session-token", "space-support", ); }); it("restores credentials when the new endpoint cannot be persisted", async () => { const previous = currentApiBase(); vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => { if (key === "rakazo.session_token") return "session-token"; return null; }); await selectSpace("space-support"); vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key) => { if (key === "rakazo.api_base") throw new Error("device locked"); }); await expect(saveApiBase("https://second-server.example")).resolves.toEqual({ ok: false, error: "Could not save the server URL", }); expect(currentApiBase()).toBe(previous); await expect(authHeaders()).resolves.toEqual({ authorization: "Bearer session-token", "x-rakazo-space-id": "space-support", }); }); it("restores a persisted space when its initial load failed", async () => { vi.mocked(SecureStore.getItemAsync).mockResolvedValue(null); await loadApiBase(); const previous = currentApiBase(); let spaceReads = 0; vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => { if (key !== "rakazo.space_id") return null; spaceReads += 1; if (spaceReads === 1) throw new Error("device locked"); return "space-support"; }); await loadApiBase(); vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key) => { if (key === "rakazo.api_base") throw new Error("device locked"); }); await expect(saveApiBase("https://second-server.example")).resolves.toEqual({ ok: false, error: "Could not save the server URL", }); expect(currentApiBase()).toBe(previous); await expect(authHeaders()).resolves.toEqual({ "x-rakazo-space-id": "space-support", }); expect(SecureStore.setItemAsync).toHaveBeenCalledWith("rakazo.space_id", "space-support"); }); it("refuses an endpoint switch when the active space cannot be snapshotted", async () => { vi.mocked(SecureStore.getItemAsync).mockResolvedValue(null); await loadApiBase(); vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => { if (key === "rakazo.space_id") throw new Error("device locked"); return null; }); await loadApiBase(); vi.mocked(SecureStore.deleteItemAsync).mockClear(); await expect(saveApiBase("https://second-server.example")).resolves.toEqual({ ok: false, error: "Could not clear the previous server session", }); expect(SecureStore.deleteItemAsync).not.toHaveBeenCalled(); }); it("preserves credentials when the active session cannot be snapshotted", async () => { await saveSessionToken("session-token"); await selectSpace("space-support"); vi.mocked(SecureStore.getItemAsync).mockRejectedValue(new Error("device locked")); vi.mocked(SecureStore.deleteItemAsync).mockClear(); const previous = currentApiBase(); const next = previous === "https://second-server.example" ? "https://third-server.example" : "https://second-server.example"; await expect(saveApiBase(next)).resolves.toEqual({ ok: false, error: "Could not clear the previous server session", }); expect(currentApiBase()).toBe(previous); expect(SecureStore.deleteItemAsync).not.toHaveBeenCalled(); vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => key === "rakazo.session_token" ? "session-token" : null, ); await expect(authHeaders()).resolves.toEqual({ authorization: "Bearer session-token", "x-rakazo-space-id": "space-support", }); }); it("keeps an invalidated empty session fail closed during credential rollback", async () => { await saveSessionToken("session-token"); await selectSpace("space-support"); vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { if (key === "rakazo.session_token") throw new Error("device locked"); }); vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { if (key === "rakazo.session_token" && value === "") throw new Error("device locked"); }); await expect(clearSessionToken()).resolves.toBe(false); vi.mocked(SecureStore.getItemAsync).mockResolvedValue("stale-session-token"); const previous = currentApiBase(); const next = previous === "https://second-server.example" ? "https://third-server.example" : "https://second-server.example"; await expect(saveApiBase(next)).resolves.toEqual({ ok: false, error: "Could not clear the previous server session", }); expect(currentApiBase()).toBe(previous); await expect(snapshotSessionToken()).resolves.toEqual({ ok: true, value: "" }); await saveSessionToken("session-token"); }); it("keeps the in-memory session across consecutive failed endpoint switches", async () => { vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => { if (key === "rakazo.session_token") return "session-token"; return null; }); await selectSpace("space-support"); vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { if (key === "rakazo.space_id") throw new Error("device locked"); }); vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { if (key === "rakazo.session_token" || (key === "rakazo.space_id" && value === "")) { throw new Error("device locked"); } }); await expect(saveApiBase("https://second-server.example")).resolves.toMatchObject({ ok: false, }); vi.mocked(SecureStore.getItemAsync).mockResolvedValue(null); await expect(saveApiBase("https://third-server.example")).resolves.toMatchObject({ ok: false }); await expect(authHeaders()).resolves.toEqual({ authorization: "Bearer session-token", "x-rakazo-space-id": "space-support", }); expect(SecureStore.setItemAsync).not.toHaveBeenCalledWith( "rakazo.api_base", expect.stringMatching(/second-server|third-server/), ); }); it("restores credentials when resetting the endpoint cannot be persisted", async () => { await saveApiBase("https://second-server.example"); const previous = currentApiBase(); vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => { if (key === "rakazo.session_token") return "session-token"; return null; }); await selectSpace("space-support"); vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { if (key === "rakazo.api_base") throw new Error("device locked"); }); await expect(resetApiBase()).resolves.toEqual({ ok: false, error: "Could not clear the custom server URL", }); expect(currentApiBase()).toBe(previous); await expect(authHeaders()).resolves.toEqual({ authorization: "Bearer session-token", "x-rakazo-space-id": "space-support", }); vi.mocked(SecureStore.getItemAsync).mockReset(); vi.mocked(SecureStore.setItemAsync).mockReset(); vi.mocked(SecureStore.deleteItemAsync).mockReset(); await resetApiBase(); }); it("recovers a deleted-space fallback over a stale saved selection after restart", async () => { const storage = new Map([["rakazo.space_id", "space-deleted"]]); vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null); vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { storage.delete(key); }); vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { if (key === "rakazo.space_id") throw new Error("device locked"); storage.set(key, value); }); await loadApiBase(); await expect(selectSpace("space-personal")).resolves.toBe(false); await expect(adoptDeletedSpaceFallback("space-personal")).resolves.toBe(true); expect(selectedSpaceId()).toBe("space-personal"); // Stale deleted id is cleared even while the replacement write stays locked. expect(storage.has("rakazo.space_id")).toBe(false); expect(storage.get("rakazo.space_rollback")).toBe( JSON.stringify({ apiBase: "http://127.0.0.1:3100", spaceId: "space-personal" }), ); vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { storage.set(key, value); }); vi.resetModules(); const restartedApi = await import("./api.js"); await restartedApi.loadApiBase(); expect(restartedApi.selectedSpaceId()).toBe("space-personal"); expect(storage.get("rakazo.space_id")).toBe("space-personal"); expect(storage.has("rakazo.space_rollback")).toBe(false); }); it("clears a deleted selection when every SecureStore write fails after delete", async () => { const storage = new Map([["rakazo.space_id", "space-deleted"]]); vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null); vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { storage.delete(key); }); vi.mocked(SecureStore.setItemAsync).mockImplementation(async () => { throw new Error("device locked"); }); await loadApiBase(); await expect(selectSpace("space-personal")).resolves.toBe(false); await expect(adoptDeletedSpaceFallback("space-personal")).resolves.toBe(true); expect(selectedSpaceId()).toBe("space-personal"); expect(storage.has("rakazo.space_id")).toBe(false); expect(storage.has("rakazo.space_rollback")).toBe(false); vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { storage.set(key, value); }); vi.resetModules(); const restartedApi = await import("./api.js"); await restartedApi.loadApiBase(); // Cleared selection lets startup fall through to the server default. expect(restartedApi.selectedSpaceId()).toBeNull(); await expect(restartedApi.selectInitialSpace("space-personal")).resolves.toBe(true); expect(restartedApi.selectedSpaceId()).toBe("space-personal"); expect(storage.get("rakazo.space_id")).toBe("space-personal"); }); it("drops a stale deleted selection when rollback recovery cannot rewrite SPACE_KEY", async () => { const storage = new Map([ ["rakazo.space_id", "space-deleted"], [ "rakazo.space_rollback", JSON.stringify({ apiBase: "http://127.0.0.1:3100", spaceId: "space-personal" }), ], ]); vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null); vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { storage.delete(key); }); vi.mocked(SecureStore.setItemAsync).mockImplementation(async () => { throw new Error("device locked"); }); vi.resetModules(); const restartedApi = await import("./api.js"); await restartedApi.loadApiBase(); expect(restartedApi.selectedSpaceId()).toBe("space-personal"); expect(storage.has("rakazo.space_id")).toBe(false); expect(storage.get("rakazo.space_rollback")).toBe( JSON.stringify({ apiBase: "http://127.0.0.1:3100", spaceId: "space-personal" }), ); }); it("recovers after restart when SecureStore could neither write nor clear after delete", async () => { const storage = new Map([["rakazo.space_id", "space-deleted"]]); vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null); vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async () => { throw new Error("device locked"); }); vi.mocked(SecureStore.setItemAsync).mockImplementation(async () => { throw new Error("device locked"); }); await loadApiBase(); await expect(selectSpace("space-personal")).resolves.toBe(false); await expect(adoptDeletedSpaceFallback("space-personal")).resolves.toBe(false); expect(selectedSpaceId()).toBe("space-personal"); expect(storage.get("rakazo.space_id")).toBe("space-deleted"); vi.resetModules(); const restartedApi = await import("./api.js"); await restartedApi.loadApiBase(); expect(restartedApi.selectedSpaceId()).toBe("space-deleted"); const fetchMock = vi .fn() .mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 })) .mockResolvedValueOnce(jsonResponse({ json: { spaces: [] } })); vi.stubGlobal("fetch", fetchMock); await expect(restartedApi.rpc("spaces/list")).resolves.toEqual({ spaces: [] }); expect(restartedApi.selectedSpaceId()).toBeNull(); expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock.mock.calls[0]![1].headers["x-rakazo-space-id"]).toBe("space-deleted"); expect(fetchMock.mock.calls[1]![1].headers["x-rakazo-space-id"]).toBeUndefined(); }); it("does not let a stale rollback override a saved deleted-space fallback", async () => { const apiBase = "http://127.0.0.1:3100"; const storage = new Map([ ["rakazo.space_id", "space-deleted"], ["rakazo.space_rollback", JSON.stringify({ apiBase, spaceId: "space-old" })], ]); vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null); vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { if (key === "rakazo.space_rollback") throw new Error("device locked"); storage.delete(key); }); vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { // clearStoredValue falls back to writing ""; keep that failing for rollback // so only an overwrite of the rollback payload can neutralize it. if (key === "rakazo.space_rollback" && value === "") throw new Error("device locked"); storage.set(key, value); }); await loadApiBase(); await expect(adoptDeletedSpaceFallback("space-personal")).resolves.toBe(true); expect(selectedSpaceId()).toBe("space-personal"); expect(storage.get("rakazo.space_id")).toBe("space-personal"); // Neutralization overwrote the stale rollback; best-effort clear may leave // the matching recovery payload when delete/empty writes stay locked. expect(storage.get("rakazo.space_rollback")).toBe( JSON.stringify({ apiBase, spaceId: "space-personal" }), ); vi.resetModules(); const restartedApi = await import("./api.js"); await restartedApi.loadApiBase(); expect(restartedApi.selectedSpaceId()).toBe("space-personal"); expect(storage.get("rakazo.space_id")).toBe("space-personal"); }); it("does not write SPACE_KEY beside a stale rollback when neutralization fails", async () => { const apiBase = "http://127.0.0.1:3100"; const storage = new Map([ ["rakazo.space_id", "space-deleted"], ["rakazo.space_rollback", JSON.stringify({ apiBase, spaceId: "space-old" })], ]); vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null); vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async () => { throw new Error("device locked"); }); vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { if (key === "rakazo.space_rollback") throw new Error("device locked"); // Block recoverSpaceRollback during loadApiBase, and block SPACE_KEY clears. // Writes of the new fallback id would succeed — the bug was doing that write // before neutralization, then failing to undo it. if (key === "rakazo.space_id" && (value === "" || value === "space-old")) { throw new Error("device locked"); } storage.set(key, value); }); await loadApiBase(); await expect(adoptDeletedSpaceFallback("space-personal")).resolves.toBe(false); expect(selectedSpaceId()).toBe("space-personal"); // Must not leave the new selection durable beside the stale rollback. expect(storage.get("rakazo.space_id")).toBe("space-deleted"); expect(storage.get("rakazo.space_rollback")).toBe( JSON.stringify({ apiBase, spaceId: "space-old" }), ); vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { if (key === "rakazo.space_rollback") throw new Error("device locked"); storage.set(key, value); }); vi.resetModules(); const restartedApi = await import("./api.js"); await restartedApi.loadApiBase(); // Restart may still recover the old rollback target, but must not have // replaced a newer SPACE_KEY fallback with it. expect(restartedApi.selectedSpaceId()).toBe("space-old"); expect(storage.get("rakazo.space_id")).toBe("space-old"); }); it("keeps the Space selection when unauthorized is a session failure", async () => { const storage = new Map([["rakazo.space_id", "space-support"]]); vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => { if (key === "rakazo.session_token") return "expired-token"; return storage.get(key) ?? null; }); vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { storage.delete(key); }); vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { storage.set(key, value); }); await loadApiBase(); expect(selectedSpaceId()).toBe("space-support"); const fetchMock = vi .fn() .mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 })) .mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 })); vi.stubGlobal("fetch", fetchMock); await expect(rpc("spaces/list")).rejects.toThrow("Unauthorized"); expect(selectedSpaceId()).toBe("space-support"); expect(storage.get("rakazo.space_id")).toBe("space-support"); expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock.mock.calls[0]![1].headers["x-rakazo-space-id"]).toBe("space-support"); expect(fetchMock.mock.calls[1]![1].headers["x-rakazo-space-id"]).toBeUndefined(); }); it("keeps a Space selected during unauthorized recovery when the retry succeeds", async () => { const storage = new Map([["rakazo.space_id", "space-deleted"]]); vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null); vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { storage.delete(key); }); vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { storage.set(key, value); }); await loadApiBase(); let resolveRetry!: (value: Response) => void; const fetchMock = vi .fn() .mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 })) .mockImplementationOnce( () => new Promise((resolve) => { resolveRetry = resolve; }), ); vi.stubGlobal("fetch", fetchMock); const pending = rpc("spaces/list"); await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); await expect(selectSpace("space-new")).resolves.toBe(true); resolveRetry(jsonResponse({ json: { spaces: [] } })); await expect(pending).resolves.toEqual({ spaces: [] }); expect(selectedSpaceId()).toBe("space-new"); expect(storage.get("rakazo.space_id")).toBe("space-new"); }); it("keeps a Space selected during unauthorized recovery when the retry fails", async () => { const storage = new Map([["rakazo.space_id", "space-deleted"]]); vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null); vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { storage.delete(key); }); vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { storage.set(key, value); }); await loadApiBase(); let resolveRetry!: (value: Response) => void; const fetchMock = vi .fn() .mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 })) .mockImplementationOnce( () => new Promise((resolve) => { resolveRetry = resolve; }), ); vi.stubGlobal("fetch", fetchMock); const pending = rpc("spaces/list"); await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); await expect(selectSpace("space-new")).resolves.toBe(true); resolveRetry(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 })); await expect(pending).rejects.toThrow("Unauthorized"); expect(selectedSpaceId()).toBe("space-new"); expect(storage.get("rakazo.space_id")).toBe("space-new"); }); it("ignores a 401 from a request sent before the user switched Spaces", async () => { const storage = new Map([["rakazo.space_id", "space-a"]]); vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null); vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { storage.delete(key); }); vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { storage.set(key, value); }); await loadApiBase(); expect(selectedSpaceId()).toBe("space-a"); let resolveStale!: (value: Response) => void; const fetchMock = vi.fn().mockImplementationOnce( () => new Promise((resolve) => { resolveStale = resolve; }), ); vi.stubGlobal("fetch", fetchMock); const stale = rpc("bots/list"); await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); expect(fetchMock.mock.calls[0]![1].headers["x-rakazo-space-id"]).toBe("space-a"); await expect(selectSpace("space-b")).resolves.toBe(true); resolveStale(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 })); // The stale request fails without probing or touching the new selection; // Space B's own requests run recovery if B is itself inaccessible. await expect(stale).rejects.toThrow("Unauthorized"); expect(fetchMock).toHaveBeenCalledTimes(1); expect(selectedSpaceId()).toBe("space-b"); expect(storage.get("rakazo.space_id")).toBe("space-b"); }); it("ignores a stale 401 after switching away and back to the same Space", async () => { const storage = new Map([["rakazo.space_id", "space-a"]]); vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null); vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { storage.delete(key); }); vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { storage.set(key, value); }); await loadApiBase(); expect(selectedSpaceId()).toBe("space-a"); let resolveStale!: (value: Response) => void; const fetchMock = vi.fn().mockImplementationOnce( () => new Promise((resolve) => { resolveStale = resolve; }), ); vi.stubGlobal("fetch", fetchMock); const stale = rpc("bots/list"); await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); expect(fetchMock.mock.calls[0]![1].headers["x-rakazo-space-id"]).toBe("space-a"); await expect(selectSpace("space-b")).resolves.toBe(true); await expect(selectSpace("space-a")).resolves.toBe(true); resolveStale(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 })); // ID-only matching would treat this obsolete Space A response as current // after A → B → A; the selection epoch must keep recovery from clearing // the newer Space A selection. await expect(stale).rejects.toThrow("Unauthorized"); expect(fetchMock).toHaveBeenCalledTimes(1); expect(selectedSpaceId()).toBe("space-a"); expect(storage.get("rakazo.space_id")).toBe("space-a"); }); it("heals durable divergence when persisting a new Space fails", async () => { const storage = new Map([["rakazo.space_id", "space-support"]]); vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null); vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { storage.delete(key); }); await loadApiBase(); await expect(selectSpace("space-support")).resolves.toBe(true); // A concurrent recovery persisted the claimed id, then the selection // write below fails and rolls the claim back. storage.set("rakazo.space_id", "space-new"); vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { if (key === "rakazo.space_id" && value === "space-new") { throw new Error("device locked"); } storage.set(key, value); }); await expect(selectSpace("space-new")).resolves.toBe(false); expect(selectedSpaceId()).toBe("space-support"); expect(storage.get("rakazo.space_id")).toBe("space-support"); }); it("keeps a newer overlapping selection when an older persist fails", async () => { const storage = new Map([["rakazo.space_id", "space-support"]]); vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null); vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { storage.delete(key); }); let rejectA!: (reason: Error) => void; vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { if (key === "rakazo.space_id" && value === "space-a") { await new Promise((_, reject) => { rejectA = reject; }); } storage.set(key, value); }); await loadApiBase(); const pendingA = selectSpace("space-a"); await vi.waitFor(() => expect(selectedSpaceId()).toBe("space-a")); await expect(selectSpace("space-b")).resolves.toBe(true); rejectA(new Error("device locked")); await expect(pendingA).resolves.toBe(false); expect(selectedSpaceId()).toBe("space-b"); expect(storage.get("rakazo.space_id")).toBe("space-b"); }); it("converges durable state to the latest overlapping selection", async () => { const storage = new Map([["rakazo.space_id", "space-support"]]); vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null); vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { storage.delete(key); }); let resolveA!: () => void; vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { if (key === "rakazo.space_id" && value === "space-a") { await new Promise((resolve) => { resolveA = resolve; }); } storage.set(key, value); }); await loadApiBase(); const pendingA = selectSpace("space-a"); await vi.waitFor(() => expect(selectedSpaceId()).toBe("space-a")); await expect(selectSpace("space-b")).resolves.toBe(true); // The older write lands stale after the newer one completed. resolveA(); await expect(pendingA).resolves.toBe(true); expect(selectedSpaceId()).toBe("space-b"); expect(storage.get("rakazo.space_id")).toBe("space-b"); }); it("keeps a later A claim when an earlier A→B→A persist fails", async () => { const storage = new Map([["rakazo.space_id", "space-support"]]); vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null); vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { storage.delete(key); }); let firstAWriteCount = 0; let rejectFirstA!: (reason: Error) => void; vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { if (key === "rakazo.space_id" && value === "space-a") { firstAWriteCount += 1; if (firstAWriteCount === 1) { await new Promise((_, reject) => { rejectFirstA = reject; }); } } storage.set(key, value); }); await loadApiBase(); const pendingFirstA = selectSpace("space-a"); await vi.waitFor(() => expect(selectedSpaceId()).toBe("space-a")); await expect(selectSpace("space-b")).resolves.toBe(true); await expect(selectSpace("space-a")).resolves.toBe(true); expect(storage.get("rakazo.space_id")).toBe("space-a"); rejectFirstA(new Error("device locked")); await expect(pendingFirstA).resolves.toBe(false); expect(selectedSpaceId()).toBe("space-a"); expect(storage.get("rakazo.space_id")).toBe("space-a"); }); it("does not let auth cleanup overwrite a newer selection with a stale snapshot", async () => { const storage = new Map([["rakazo.space_id", "space-deleted"]]); vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null); let injected = false; let holdCleanupReconcile = false; let releaseCleanupWrite!: () => void; const cleanupWriteHeld = new Promise((resolve) => { releaseCleanupWrite = resolve; }); vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { if (key === "rakazo.space_id" && !injected) { injected = true; // Finish selecting B before cleanup snapshots it for reconcile. await expect(selectSpace("space-b")).resolves.toBe(true); holdCleanupReconcile = true; } storage.delete(key); }); vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { if (key === "rakazo.space_id" && value === "space-b" && holdCleanupReconcile) { holdCleanupReconcile = false; // Hold only the post-clear reconcile write of the B snapshot. await cleanupWriteHeld; } storage.set(key, value); }); await loadApiBase(); const fetchMock = vi .fn() .mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 })) .mockResolvedValueOnce(jsonResponse({ json: { spaces: [] } })); vi.stubGlobal("fetch", fetchMock); const pendingRpc = rpc("spaces/list"); await vi.waitFor(() => expect(selectedSpaceId()).toBe("space-b")); await expect(selectSpace("space-c")).resolves.toBe(true); expect(storage.get("rakazo.space_id")).toBe("space-c"); releaseCleanupWrite(); await expect(pendingRpc).resolves.toEqual({ spaces: [] }); expect(selectedSpaceId()).toBe("space-c"); expect(storage.get("rakazo.space_id")).toBe("space-c"); }); it("re-persists a Space selected while recovery cleanup is in flight", async () => { const storage = new Map([["rakazo.space_id", "space-deleted"]]); vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null); let injected = false; vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { if (key === "rakazo.space_id" && !injected) { injected = true; await expect(selectSpace("space-new")).resolves.toBe(true); } storage.delete(key); }); vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { storage.set(key, value); }); await loadApiBase(); const fetchMock = vi .fn() .mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 })) .mockResolvedValueOnce(jsonResponse({ json: { spaces: [] } })); vi.stubGlobal("fetch", fetchMock); await expect(rpc("spaces/list")).resolves.toEqual({ spaces: [] }); expect(injected).toBe(true); expect(selectedSpaceId()).toBe("space-new"); expect(storage.get("rakazo.space_id")).toBe("space-new"); }); it("does not retry a mutating RPC against the default Space after Space auth failure", async () => { const storage = new Map([["rakazo.space_id", "space-deleted"]]); vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null); vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { storage.delete(key); }); vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { storage.set(key, value); }); await loadApiBase(); const fetchMock = vi .fn() .mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 })) .mockResolvedValueOnce(jsonResponse({ json: { spaces: [] } })); vi.stubGlobal("fetch", fetchMock); await expect(rpc("bots/create", { name: "Wrong space bot" })).rejects.toThrow("Unauthorized"); expect(selectedSpaceId()).toBeNull(); expect(storage.has("rakazo.space_id")).toBe(false); expect(fetchMock).toHaveBeenCalledTimes(2); expect(String(fetchMock.mock.calls[0]![0])).toContain("/rpc/bots/create"); expect(fetchMock.mock.calls[0]![1].headers["x-rakazo-space-id"]).toBe("space-deleted"); expect(String(fetchMock.mock.calls[1]![0])).toContain("/rpc/spaces/list"); expect(fetchMock.mock.calls[1]![1].headers["x-rakazo-space-id"]).toBeUndefined(); }); it("keeps the Space selection when a mutating RPC unauthorized is a session failure", async () => { const storage = new Map([["rakazo.space_id", "space-support"]]); vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => { if (key === "rakazo.session_token") return "expired-token"; return storage.get(key) ?? null; }); vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { storage.delete(key); }); vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { storage.set(key, value); }); await loadApiBase(); const fetchMock = vi .fn() .mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 })) .mockResolvedValueOnce(jsonResponse({ error: { message: "Unauthorized" } }, { status: 401 })); vi.stubGlobal("fetch", fetchMock); await expect(rpc("bots/create", { name: "Should not land" })).rejects.toThrow("Unauthorized"); expect(selectedSpaceId()).toBe("space-support"); expect(storage.get("rakazo.space_id")).toBe("space-support"); expect(fetchMock).toHaveBeenCalledTimes(2); expect(String(fetchMock.mock.calls[0]![0])).toContain("/rpc/bots/create"); expect(fetchMock.mock.calls[0]![1].headers["x-rakazo-space-id"]).toBe("space-support"); expect(String(fetchMock.mock.calls[1]![0])).toContain("/rpc/spaces/list"); expect(fetchMock.mock.calls[1]![1].headers["x-rakazo-space-id"]).toBeUndefined(); }); it("recovers the active space after rollback persistence fails", async () => { vi.mocked(SecureStore.getItemAsync).mockResolvedValue(null); await loadApiBase(); await selectSpace("space-support"); const storage = new Map(); vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null); vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { storage.delete(key); }); vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { if (key === "rakazo.api_base" || (key === "rakazo.space_id" && value === "space-support")) { throw new Error("device locked"); } storage.set(key, value); }); await expect(saveApiBase("https://second-server.example")).resolves.toEqual({ ok: false, error: "Could not save the server URL", }); await expect(authHeaders()).resolves.toEqual({ "x-rakazo-space-id": "space-support", }); expect(storage.get("rakazo.space_rollback")).toBe( JSON.stringify({ apiBase: "http://127.0.0.1:3100", spaceId: "space-support" }), ); vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { storage.set(key, value); }); vi.resetModules(); const restartedApi = await import("./api.js"); await restartedApi.loadApiBase(); expect(restartedApi.selectedSpaceId()).toBe("space-support"); expect(storage.get("rakazo.space_id")).toBe("space-support"); expect(storage.has("rakazo.space_rollback")).toBe(false); }); it("does not recover a space on a different endpoint", async () => { const storage = new Map([ ["rakazo.api_base", "https://second-server.example"], [ "rakazo.space_rollback", JSON.stringify({ apiBase: "http://127.0.0.1:3100", spaceId: "space-support" }), ], ]); vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null); vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { storage.set(key, value); }); vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { storage.delete(key); }); vi.resetModules(); const restartedApi = await import("./api.js"); await expect(restartedApi.loadApiBase()).resolves.toBe("https://second-server.example"); expect(restartedApi.selectedSpaceId()).toBeNull(); expect(storage.has("rakazo.space_rollback")).toBe(false); }); it("removes a malformed space rollback record", async () => { const storage = new Map([["rakazo.space_rollback", "null"]]); vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => storage.get(key) ?? null); vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { storage.set(key, value); }); vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { storage.delete(key); }); vi.resetModules(); const restartedApi = await import("./api.js"); await restartedApi.loadApiBase(); expect(restartedApi.selectedSpaceId()).toBeNull(); expect(storage.has("rakazo.space_rollback")).toBe(false); }); }); describe("mobile thread subscription", () => { beforeEach(() => { vi.restoreAllMocks(); vi.mocked(SecureStore.getItemAsync).mockReset(); vi.mocked(SecureStore.getItemAsync).mockResolvedValue(""); }); it("parses fragmented SSE frames and ignores malformed data and completion markers", async () => { const encoder = new TextEncoder(); const stream = new ReadableStream({ start(controller) { controller.enqueue(encoder.encode('data: {"json":{"type":"thread.pro')); controller.enqueue( encoder.encode( 'gress","seq":4,"payload":{"delta":"Hi"}}}\n\ndata: not-json\n\ndata: [DONE]\n\n', ), ); controller.enqueue( encoder.encode( 'data: {"json":{"type":"thread.message.created",\n' + 'data: "seq":5,"payload":{"messageId":"m1"}}}\n\n', ), ); controller.close(); }, }); const fetchMock = vi.fn(async () => new Response(stream, { status: 200 })); vi.stubGlobal("fetch", fetchMock); const onEvent = vi.fn(); await subscribeThread({ botId: "bot-1" }, 3, onEvent, new AbortController().signal); expect(fetchMock).toHaveBeenCalledWith( "http://127.0.0.1:3100/rpc/threads/subscribe", expect.objectContaining({ headers: expect.objectContaining({ accept: "text/event-stream" }), body: JSON.stringify({ json: { botId: "bot-1", cursor: 3 } }), }), ); expect(onEvent).toHaveBeenCalledTimes(2); expect(onEvent).toHaveBeenNthCalledWith( 1, expect.objectContaining({ type: "thread.progress", seq: 4 }), ); expect(onEvent).toHaveBeenNthCalledWith( 2, expect.objectContaining({ type: "thread.message.created", seq: 5 }), ); }); it("rejects responses that are unsuccessful or have no stream body", async () => { vi.stubGlobal( "fetch", vi.fn(async () => new Response(null, { status: 503 })), ); await expect( subscribeThread({ botId: "bot-1" }, -1, vi.fn(), new AbortController().signal), ).rejects.toThrow("rpc threads/subscribe failed (503)"); vi.stubGlobal( "fetch", vi.fn(async () => new Response(null, { status: 200 })), ); await expect( subscribeThread({ botId: "bot-1" }, -1, vi.fn(), new AbortController().signal), ).rejects.toThrow("rpc threads/subscribe failed (200)"); }); }); describe("mobile thread refresh targeting", () => { it("drops a deferred group A refresh after navigation to group B", async () => { let activeGroupId: string | undefined = "group-a"; let currentEpoch = 1; let resolveRequest!: (snapshot: MobileSnapshot) => void; const request = new Promise((resolve) => { resolveRequest = resolve; }); let applied: MobileSnapshot | null = null; const refresh = request.then((snapshot) => { if ( shouldApplyMobileThreadRefresh({ requestEpoch: 1, currentEpoch, targetBotId: undefined, targetGroupId: "group-a", activeBotId: undefined, activeGroupId, }) ) { applied = snapshot; } }); activeGroupId = "group-b"; currentEpoch += 1; resolveRequest({ groupId: "group-a", threadId: "thread-a", messages: [], olderCursor: null, run: null, }); await refresh; expect(applied).toBeNull(); }); }); describe("mobile thread event reduction", () => { it("appends an emoji reply with its exact target", () => { const initial = snapshot([mobileMessage("message-1", [{ kind: "text", text: "Done" }])]); const next = applyMobileThreadEvent(initial, { type: "thread.message.created", seq: 4, payload: { messageId: "reaction-1", role: "user", blocks: [{ kind: "text", text: "❤️" }], replyToMessageId: "message-1", }, }); expect(next?.messages.find((message) => message.id === "reaction-1")).toMatchObject({ role: "user", blocks: [{ kind: "text", text: "❤️" }], replyToMessageId: "message-1", }); expect(next?.cursor).toBe(4); }); it("appends a quoted reply carrying its excerpt", () => { const initial = snapshot([mobileMessage("message-1", [{ kind: "text", text: "Done" }])]); const next = applyMobileThreadEvent(initial, { type: "thread.message.created", seq: 4, payload: { messageId: "reply-1", role: "user", blocks: [{ kind: "text", text: "why this?" }], replyToMessageId: "message-1", replyQuote: "Done", }, }); expect(next?.messages.find((message) => message.id === "reply-1")).toMatchObject({ role: "user", replyToMessageId: "message-1", replyQuote: "Done", }); }); it("prepends ordered history pages without duplicating the boundary message", () => { const initial = snapshot([mobileMessage("m-2", [], 2), mobileMessage("m-3", [], 3)], 2); const next = prependMobileMessagePage(initial, { threadId: "thread-1", messages: [ mobileMessage("m-0", [], 0), mobileMessage("m-1", [], 1), mobileMessage("m-2", [], 2), ], olderCursor: null, }); expect(next?.messages.map((item) => item.id)).toEqual(["m-0", "m-1", "m-2", "m-3"]); expect(next?.olderCursor).toBeNull(); }); it("retains loaded history across refresh while dropping stale progress", () => { const initial = snapshot( [ mobileMessage("m-0", [], 0), mobileMessage("m-1", [], 1), mobileMessage("progress:run-1", [{ kind: "progress", text: "draft" }], 8), ], null, ); const refreshed = snapshot([mobileMessage("m-1", [], 1), mobileMessage("m-2", [], 2)], 1); const next = mergeMobileSnapshot(initial, refreshed, true); expect(next.messages.map((item) => item.id)).toEqual(["m-0", "m-1", "m-2"]); expect(next.olderCursor).toBeNull(); }); it("accumulates progress deltas for the same run", () => { const first = applyMobileThreadEvent(snapshot(), { type: "thread.progress", runId: "run-1", payload: { delta: "Hel" }, }); const second = applyMobileThreadEvent(first, { type: "thread.progress", runId: "run-1", payload: { delta: "lo" }, }); expect(second?.messages).toEqual([ { id: "progress:run-1", role: "bot", runId: "run-1", blocks: [{ kind: "progress", text: "Hello" }], }, ]); }); it("preserves progress from a legacy run-only snapshot", () => { const initial: MobileSnapshot = { ...snapshot([ { ...mobileMessage("progress:run-legacy", [{ kind: "progress", text: "Still working" }]), runId: "run-legacy", }, ]), run: { id: "run-legacy", status: "running" }, activeRuns: undefined, }; const next = applyMobileThreadEvent(initial, { type: "thread.progress", runId: "run-new", payload: { text: "New work" }, }); expect(next?.messages.map((item) => item.id)).toEqual([ "progress:run-legacy", "progress:run-new", ]); }); it("preserves concurrent progress when active-run metadata lags", () => { const initial: MobileSnapshot = { ...snapshot([ { ...mobileMessage("progress:run-concurrent", [ { kind: "progress", text: "Concurrent work" }, ]), runId: "run-concurrent", }, ]), run: { id: "run-current", status: "running" }, activeRuns: [{ id: "run-current", status: "running" }], }; const next = applyMobileThreadEvent(initial, { type: "thread.progress", runId: "run-current", payload: { text: "Current work" }, }); expect(next?.messages.map((item) => item.id)).toEqual([ "progress:run-concurrent", "progress:run-current", ]); }); it("holds live tool steps until mobile narration reaches a sentence boundary", () => { const narration = applyMobileThreadEvent(snapshot(), { type: "thread.progress", runId: "run-1", payload: { text: "Let me check " }, }); const pending = applyMobileThreadEvent(narration, { type: "agent.tool.called", runId: "run-1", payload: { name: "SLACK_FIND_CHANNELS" }, }); const completed = applyMobileThreadEvent(pending, { type: "thread.progress", runId: "run-1", payload: { delta: "now." }, }); expect(pending?.messages[0]?.blocks).toEqual([ { kind: "progress", text: "Let me check ", pendingToolNames: ["SLACK_FIND_CHANNELS"], }, ]); expect(completed?.messages[0]?.blocks).toEqual([ { kind: "text", text: "Let me check now." }, { kind: "steps", steps: [{ label: "Slack find channels", count: 1 }] }, ]); expect(blockText(completed?.messages[0] as MobileMessage)).toBe( "Let me check now.\nSlack find channels", ); }); it("formats channel messages with their platform attribution", () => { expect( blockText( mobileMessage("channel-1", [ { kind: "channel_message", provider: "sendblue", transport: "SMS", channelId: "ch-1", fromAddress: "+15551234567", fromLabel: "Alex", text: "Hello from the group", }, ]), ), ).toBe("SMS · Alex: Hello from the group"); expect( blockText( mobileMessage("channel-2", [ { kind: "channel_message", provider: "slack", channelId: "ch-2", fromAddress: "U123456", fromLabel: "Alex", text: "Hello from the group", }, ]), ), ).toBe("Slack · Alex: Hello from the group"); }); it("falls back to plain text and file rows for video, audio and media cards", () => { const blocks = [ { kind: "video" as const, artifactId: "vid-1", mimeType: "video/mp4", name: "clip.mp4", size: 10, width: 1080, height: 1920, }, { kind: "audio" as const, artifactId: "aud-1", mimeType: "audio/mpeg", name: "memo.mp3", size: 5, }, { kind: "media" as const, jobId: "job-1", operation: "export_video" as const, status: "done" as const, title: "Vertical cut", outputs: [ { artifactId: "out-1", kind: "video" as const, mimeType: "video/mp4", name: "cut.mp4", size: 7, }, ], }, ]; expect(blockText(mobileMessage("media-1", blocks))).toBe( "[video: clip.mp4]\n[audio: memo.mp3]\nVertical cut: done", ); expect(messageAttachments(blocks).map((item) => [item.kind, item.artifactId])).toEqual([ ["video", "vid-1"], ["audio", "aud-1"], ["video", "out-1"], ]); }); it("deduplicates durable messages and replaces matching transient subagent state", () => { const initial = snapshot([ mobileMessage("message-1", [{ kind: "text", text: "old" }]), mobileMessage("subagent:research", [ { kind: "subagent", agentId: "research", name: "Research", task: "Search", status: "running", }, ]), mobileMessage("subagent:other", [ { kind: "subagent", agentId: "other", name: "Other", task: "Wait", status: "running" }, ]), { ...mobileMessage("progress:run-1", [{ kind: "progress", text: "draft" }]), runId: "run-1", }, ]); const completed = { kind: "subagent" as const, agentId: "research", name: "Research", task: "Search", status: "completed" as const, result: "Done", }; const next = applyMobileThreadEvent(initial, { id: "event-1", type: "thread.message.created", seq: 9, runId: "run-1", payload: { messageId: "message-1", role: "bot", blocks: [completed] }, }); expect(next?.messages.map((item) => item.id)).toEqual(["message-1", "subagent:other"]); expect(next?.messages[0]?.blocks).toEqual([completed]); }); it("keeps a replayed bot-to-bot marker in its durable transcript position", () => { const peerBlock = { kind: "bot_message_received" as const, fromBotId: "bot-peer", fromBotName: "Peer", text: "Please check this.", }; const initial = snapshot([ mobileMessage("peer-message", [peerBlock], 1), mobileMessage("newer-message", [{ kind: "text", text: "Working on it." }], 2), ]); const next = applyMobileThreadEvent(initial, { type: "thread.message.created", seq: 9, payload: { messageId: "peer-message", role: "user", blocks: [peerBlock] }, }); expect(next?.messages.map((message) => message.id)).toEqual(["peer-message", "newer-message"]); }); it("clears loaded history and active state when another client clears the thread", () => { const initial = snapshot([mobileMessage("message-1", [{ kind: "text", text: "old" }])], 1); initial.run = { id: "run-1", status: "running" }; const next = applyMobileThreadEvent(initial, { type: "thread.cleared", seq: 12 }); expect(next).toMatchObject({ cursor: 12, messages: [], olderCursor: null, run: null }); }); it("applies the durable waiting-input run transition", () => { const initial: MobileSnapshot = { ...snapshot(), run: { id: "run-1", status: "running" } }; const waiting = applyMobileThreadEvent(initial, { type: "run.waiting_input", runId: "run-1", seq: 8, }); expect(waiting?.run?.status).toBe("waiting_input"); expect(waiting?.cursor).toBe(8); const repeated = applyMobileThreadEvent(waiting, { type: "run.waiting_input", runId: "run-1", seq: 9, }); expect(repeated?.cursor).toBe(9); expect(repeated?.run).toBe(waiting?.run); }); it("applies computer takeover requests as waiting_takeover", () => { const progress = mobileMessage("progress:run-1", [{ kind: "progress", text: "working…" }], 1); const initial: MobileSnapshot = { ...snapshot([progress]), run: { id: "run-1", status: "running" }, activeRuns: [{ id: "run-1", status: "running" }], }; const waiting = applyMobileThreadEvent(initial, { type: "computer.takeover.requested", runId: "run-1", seq: 10, }); expect(waiting?.run?.status).toBe("waiting_takeover"); expect(waiting?.activeRuns?.[0]?.status).toBe("waiting_takeover"); expect(waiting?.messages.some((message) => message.id.startsWith("progress:"))).toBe(false); expect(waiting?.cursor).toBe(10); }); it("inserts a peer takeover run that was absent from the open snapshot", () => { const progress = mobileMessage( "progress:run-peer", [{ kind: "progress", text: "working…" }], 1, ); const initial: MobileSnapshot = { ...snapshot([progress]), run: { id: "run-user", status: "running" }, activeRuns: [{ id: "run-user", status: "running" }], computer: { state: "running", controlHolder: "bot", screenAvailable: true, mode: "team", busyBotName: "Peer", }, }; const waiting = applyMobileThreadEvent(initial, { type: "computer.takeover.requested", runId: "run-peer", botId: "bot-peer", seq: 12, }); expect(waiting?.run).toEqual({ id: "run-peer", botId: "bot-peer", status: "waiting_takeover" }); expect(waiting?.activeRuns).toEqual([ { id: "run-user", status: "running" }, { id: "run-peer", botId: "bot-peer", status: "waiting_takeover" }, ]); expect(waiting?.messages.some((message) => message.id.startsWith("progress:"))).toBe(false); expect(waiting?.computer?.busyBotName).toBeNull(); }); it("advances the cursor for durable message events", () => { const next = applyMobileThreadEvent(snapshot(), { type: "thread.message.created", seq: 11, payload: { messageId: "message-1", role: "bot", blocks: [{ kind: "text", text: "Done" }] }, }); expect(next?.cursor).toBe(11); }); it("preserves ask actions and runId on created messages", () => { const initial = snapshot(); const askBlock = { kind: "ask", text: "Review before writing", detail: "title: Result", status: "pending", actions: [ { id: "allow", label: "Allow once" }, { id: "always", label: "Always allow" }, { id: "deny", label: "Deny" }, ], }; const next = applyMobileThreadEvent(initial, { type: "thread.message.created", runId: "run-1", payload: { messageId: "message-ask", role: "bot", blocks: [askBlock] }, }); expect(next?.messages.at(-1)).toMatchObject({ id: "message-ask", runId: "run-1", blocks: [askBlock], }); }); it("updates a waiting group run without replacing the newer active run", () => { const initial: MobileSnapshot = { ...snapshot(), run: { id: "run-newer", status: "running" }, activeRuns: [ { id: "run-newer", status: "running" }, { id: "run-waiting", status: "running" }, ], }; const waiting = applyMobileThreadEvent(initial, { type: "run.waiting_input", runId: "run-waiting", }); expect(waiting?.run).toEqual({ id: "run-newer", status: "running" }); expect(waiting?.activeRuns).toEqual([ { id: "run-newer", status: "running" }, { id: "run-waiting", status: "waiting_input" }, ]); }); it("clears only the terminal run's live progress", () => { const runA = { id: "run-a", status: "running" }; const runB = { id: "run-b", status: "running" }; const initial: MobileSnapshot = { ...snapshot([ { ...mobileMessage("progress:run-a", [{ kind: "progress", text: "A" }]), runId: runA.id, }, { ...mobileMessage("progress:run-b", [{ kind: "progress", text: "B" }]), runId: runB.id, }, ]), run: runA, activeRuns: [runA, runB], }; const next = applyMobileThreadEvent(initial, { type: "run.cancelled", seq: 10, runId: runA.id, }); expect(next?.messages.map((item) => item.id)).toEqual(["progress:run-b"]); expect(next?.run).toEqual(runB); expect(next?.activeRuns).toEqual([runB]); expect(next?.cursor).toBe(10); }); it("keeps a failed member run's error while another member run is still active", () => { const runA = { id: "run-a", status: "running" }; const runB = { id: "run-b", status: "running" }; const initial: MobileSnapshot = { ...snapshot([ { ...mobileMessage("progress:run-b", [{ kind: "progress", text: "B" }]), runId: runB.id, }, ]), run: runA, activeRuns: [runA, runB], }; const next = applyMobileThreadEvent(initial, { type: "run.failed", seq: 11, runId: runB.id, payload: { error: "member exploded" }, }); expect(next?.activeRuns).toEqual([runA]); expect(next?.run).toEqual({ id: runB.id, status: "failed", error: "member exploded" }); expect(next?.messages).toEqual([]); }); it("updates a cloud agent card from thread.cloud_agent", () => { const initial = snapshot([ mobileMessage("msg-ca", [ { kind: "cloud_agent", agentId: "ca-1", title: "Add README", status: "running", url: "https://example.test/agents/ca-1", }, ]), ]); const next = applyMobileThreadEvent(initial, { type: "thread.cloud_agent", seq: 9, payload: { messageId: "msg-ca", agentId: "ca-1", title: "Add README", status: "finished", url: "https://example.test/agents/ca-1", branch: "cursor/add-readme", prUrl: "https://github.com/example/repo/pull/1", }, }); expect(next?.cursor).toBe(9); expect(next?.messages[0]?.blocks[0]).toEqual({ kind: "cloud_agent", agentId: "ca-1", title: "Add README", status: "finished", url: "https://example.test/agents/ca-1", branch: "cursor/add-readme", prUrl: "https://github.com/example/repo/pull/1", }); }); it("leaves the snapshot unchanged for unrelated events", () => { const initial = snapshot(); expect(applyMobileThreadEvent(initial, { type: "run.started" })).toBe(initial); expect(applyMobileThreadEvent(null, { type: "thread.progress" })).toBeNull(); }); }); function jsonResponse(body: unknown, init?: ResponseInit) { return new Response(JSON.stringify(body), { headers: { "content-type": "application/json" }, ...init, }); } function snapshot( messages: MobileMessage[] = [], olderCursor: number | null = null, ): MobileSnapshot { return { botId: "bot-1", threadId: "thread-1", cursor: 3, messages, olderCursor, run: null, computer: { state: "running", controlHolder: "bot", screenAvailable: true, mode: "team", busyBotName: null, }, }; } function mobileMessage(id: string, blocks: MobileMessage["blocks"], seq?: number): MobileMessage { return { id, threadId: "thread-1", seq, role: "bot", blocks }; } describe("mobile clipboard text", () => { it("copies message content with transport labels and omits card chrome", async () => { const { copyableMobileMessageText } = await import("./api"); expect( copyableMobileMessageText({ id: "message", role: "bot", blocks: [ { kind: "text", text: "Hello" }, { kind: "channel_message", provider: "sendblue", transport: "SMS", channelId: "ch-1", fromAddress: "+15551234567", fromLabel: "Sender", text: "Reply", }, { kind: "card", lines: [] }, ], }), ).toBe("Hello\nSMS · Sender: Reply"); }); });