evOo-local/apps/mobile/lib/api.test.ts
Claude d3689b88fc
feat(onboarding): show the onboarding cards and check-in names in the user's language
The onboarding cards, the closing line and the coach routine names were English server text
for every user. The server keeps the state and an English fallback; the web now words them
through Lingui, so there is one translation source.

- Choice cards: the web says the question, the option labels and the placeholder from the
  step and option ids, and keeps the server text for anything it does not know (quiet-hour
  ranges are already written in the user's locale). Another bot's focus card is recognized
  by its option ids. Mobile and agent history keep the server text.
- The "Got it. … are a good place to start." line is gone: the app cards speak for
  themselves, and with no cards nothing is said.
- The closing line becomes an intake_note block (note "done", nextCheckInAt, English text).
  The web words it with the first check-in in the user's clock; mobile blockText and
  blocksToAgentHistoryText read its text through their generic fallback.
- Routines carry coachKey. The routine list and editor show a check-in under its label in
  the UI language until the user renames it; saving an untouched label keeps the stored
  name. The English names move to contracts (COACH_ROUTINE_NAMES); prompts stay English.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 14:51:59 +00:00

2247 lines
82 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
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<Response>((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<Response>(() => 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<void>(() => {});
});
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<string, string>([["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<string, string>([["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<string, string>([
["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<string, string>([["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<string, string>([
["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<string, string>([
["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<string, string>([["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<string, string>([["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<Response>((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<string, string>([["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<Response>((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<string, string>([["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<Response>((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<string, string>([["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<Response>((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<string, string>([["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<string, string>([["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<never>((_, 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<string, string>([["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<void>((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<string, string>([["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<never>((_, 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<string, string>([["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<void>((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<string, string>([["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<string, string>([["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<string, string>([["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<string, string>();
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<Uint8Array>({
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<MobileSnapshot>((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("reads a choice card as its question and answer", () => {
const card = {
kind: "choice" as const,
step: "goal" as const,
question: "What’s one thing you want to get done in the next few weeks?",
textInput: { placeholder: "Your goal" },
options: [{ id: "skip", letter: "", label: "Skip" }],
};
expect(blockText(mobileMessage("choice-1", [card]))).toBe(card.question);
expect(
blockText(
mobileMessage("choice-2", [{ ...card, answerId: "_text", answerText: "Run a 10K" }]),
),
).toBe(`${card.question}\nRun a 10K`);
expect(blockText(mobileMessage("choice-3", [{ ...card, answerId: "skip" }]))).toBe(
`${card.question}\nSkip`,
);
const note = {
kind: "intake_note" as const,
note: "done" as const,
nextCheckInAt: null,
text: "All set. I’m here whenever you need me.",
};
expect(blockText(mobileMessage("note-1", [note]))).toBe(note.text);
});
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");
});
});