* Recommend Claude Opus 5.5 by bumping the Pi model catalog The pinned pi-ai 0.85.1 had no Claude Opus 5.5, so the connect flow fell back to Opus 5. pi-ai and pi-agent-core 0.87.1 add it, and the keyless recommendation now preselects it. The new catalog names router aliases "Qwen Max Latest (Qwen3.8 Max)"; those now read "(auto-updates)" like the other alias labels, so no picker entry claims to be "latest". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD * Name HTTPS as the cause when dictation is unavailable, and translate it Over plain HTTP on a LAN address, Safari hides the microphone and speech recognition, so voice mode said the browser had no dictation, in English inside a German UI. Dictation now checks for a secure context first and says to open the address with https://. Its other errors go through Lingui too, and Safari's blocked recognizer gets a clear message instead of "Dictation failed: not-allowed". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD * Serve a fake Kokoro voice server in the E2E harness Onboarding's voice step connects to the local Kokoro server on its default port, so the shell E2E failed on any machine where nothing (or a misbehaving server) listened there: the failed request shows up as a browser console error. The harness now starts an offline stand-in and points KOKORO_BASE_URL at it, so the suite no longer depends on the runner host's own voice server. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD * Show only the recommended sign-in until a model is connected With no model connected, "Connect model" opened the full model settings: provider list, tiers and a model picker, even though the recommendation was already preselected. Until something is connected, the Models panel now shows just the preselected model with its sign-in and API key field; "Other providers" reveals the full settings. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD * Give the second chart-links test the same cold-load allowance as the first Each Playwright test runs in a fresh browser context, so the second test's first navigation re-fetches the lazily imported Plot chunk just like the first test's warm-up does. On a loaded CI runner that exceeded the default 20 s expect timeout. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD * Use the recommended model as the Anthropic deployment default A deployment that sets only ANTHROPIC_API_KEY and PI_DEFAULT_PROVIDER=anthropic now runs Claude Opus 5.5, the same model the connect flow preselects, instead of an older default. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD * Run every space on the deployment's local models without a connect step A deployment with PI_DEFAULT_PROVIDER=local and RAKAZO_LOCAL_MODELS set still asked every user to connect a model, because only OpenRouter and Anthropic counted as a deployment model. Configured local models now do, keylessly. RAKAZO_LOCAL_TIERS assigns local models to the tiers the agent delegates to, for spaces that have not mapped their own, and a RAKAZO_LOCAL_MODELS entry can name its own server (id=url) when the models run behind different endpoints. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD * Hide model choices when the deployment routes models itself When the server provides its own models, the system picks and routes them per task, so Settings no longer offers Models and bot settings no longer offer Model and Thinking, on web and mobile. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD * Wait for the workspace pane's width transition before measuring it The pane animates its width over 300 ms, so a single boundingBox read right after it opens can land mid-transition on a slow runner. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD * Allow chart re-renders inside the chart-links loops to take longer Each navigation in the loops renders Observable Plot again. On a loaded CI runner that exceeded the default expect timeout even after the warm-up, so the per-iteration visibility checks get 30 s. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KWEAjTw26eRf3ZXTXfRjmD --------- Co-authored-by: Claude <noreply@anthropic.com>
1195 lines
41 KiB
TypeScript
1195 lines
41 KiB
TypeScript
import type {
|
||
Bot,
|
||
BotSection,
|
||
ComputerMode,
|
||
Group,
|
||
Me,
|
||
MessageBlock,
|
||
ModelCatalogEntry,
|
||
ModelCredential,
|
||
Space,
|
||
SpaceNavigation,
|
||
} from "@rakazo/contracts";
|
||
import type { ThreadHistory } from "@rakazo/core";
|
||
import {
|
||
aiConsentTarget,
|
||
aiDataUsesForProcedure,
|
||
cancelResponseBody,
|
||
ensureAiDataConsent,
|
||
isRunTerminalEvent,
|
||
mergeThreadHistory,
|
||
prependThreadHistoryPage,
|
||
progressMessageId,
|
||
readBoundedJsonResponse,
|
||
reduceLiveMessageBlocks,
|
||
runFailureError,
|
||
signupRequiresEmailVerification,
|
||
takeLiveMessage,
|
||
updateCloudAgentMessages,
|
||
upsertMessageById,
|
||
} from "@rakazo/core";
|
||
import * as SecureStore from "expo-secure-store";
|
||
import { promptAiConsent } from "./ai-consent";
|
||
import type { EndpointResult } from "./endpoint";
|
||
import { defaultApiBase, normalizeApiBase } from "./endpoint";
|
||
import { t } from "./i18n";
|
||
import { resumeLiveNotifications } from "./live-notifications";
|
||
import {
|
||
clearSessionToken,
|
||
loadSessionToken,
|
||
restoreSessionToken,
|
||
saveSessionToken,
|
||
snapshotSessionToken,
|
||
tokenFromAuthResponse,
|
||
} from "./session";
|
||
|
||
const ENDPOINT_KEY = "rakazo.api_base";
|
||
const SPACE_KEY = "rakazo.space_id";
|
||
const SPACE_ROLLBACK_KEY = "rakazo.space_rollback";
|
||
const RPC_TIMEOUT_MS = 8_000;
|
||
export const MAX_MOBILE_AUTH_RESPONSE_BYTES = 256 * 1024;
|
||
export const MAX_MOBILE_RPC_RESPONSE_BYTES = 16 * 1024 * 1024;
|
||
/** Inbox bootstrap reads safe to replay without a Space header during auth recovery. */
|
||
const SPACE_AUTH_RECOVERY_SAFE_PROCS = new Set(["spaces/list", "me"]);
|
||
|
||
let cachedApiBase: string | undefined;
|
||
let cachedSpaceId = "";
|
||
/** Bumped on every in-memory Space selection change so a delayed response
|
||
* cannot treat a later reselection of the same Space id as its own. */
|
||
let spaceSelectionGeneration = 0;
|
||
|
||
function bumpSpaceSelectionGeneration(): void {
|
||
spaceSelectionGeneration += 1;
|
||
}
|
||
|
||
function responseErrorMessage(body: unknown, fallback: string): string {
|
||
return typeof body === "object" && body && "message" in body
|
||
? String((body as { message?: string }).message ?? fallback)
|
||
: fallback;
|
||
}
|
||
|
||
export function currentApiBase() {
|
||
const parsed = normalizeApiBase(cachedApiBase ?? defaultApiBase());
|
||
if (!parsed.ok) throw new Error(parsed.error);
|
||
return parsed.url;
|
||
}
|
||
|
||
export async function loadApiBase() {
|
||
let apiBase = defaultApiBase();
|
||
try {
|
||
const stored = await SecureStore.getItemAsync(ENDPOINT_KEY);
|
||
if (stored) {
|
||
const parsed = normalizeApiBase(stored);
|
||
if (parsed.ok) {
|
||
apiBase = parsed.url;
|
||
}
|
||
}
|
||
} catch {
|
||
// SecureStore is unavailable in some test / web hosts.
|
||
}
|
||
cachedApiBase = apiBase;
|
||
try {
|
||
const storedSpace = (await SecureStore.getItemAsync(SPACE_KEY)) ?? "";
|
||
cachedSpaceId = storedSpace;
|
||
bumpSpaceSelectionGeneration();
|
||
// A deletion fallback must override the now-invalid saved Space even when
|
||
// the device failed to replace that value before the previous process exited.
|
||
await recoverSpaceRollback(cachedApiBase);
|
||
} catch {
|
||
// Keep any in-memory selection when SecureStore is temporarily unavailable.
|
||
}
|
||
return cachedApiBase;
|
||
}
|
||
|
||
export async function selectSpace(id: string) {
|
||
if (!(await clearStoredValue(SPACE_ROLLBACK_KEY))) return false;
|
||
// Claim memory before persisting: recovery paths reconcile against the
|
||
// in-memory selection, so a durable write must never precede its owner.
|
||
const previousSpaceId = cachedSpaceId;
|
||
cachedSpaceId = id;
|
||
bumpSpaceSelectionGeneration();
|
||
// Generation ownership distinguishes A→B→A from "we still own this claim":
|
||
// an ID-only check would treat a later same-id selection as ours.
|
||
const claimGeneration = spaceSelectionGeneration;
|
||
try {
|
||
await SecureStore.setItemAsync(SPACE_KEY, id);
|
||
// Our write may have landed stale behind a newer overlapping selection's
|
||
// write. Re-assert the live selection best-effort so durable converges
|
||
// to it; each selector heals at most once, so overlapping chains settle
|
||
// on the latest claim. Never delete here: a missing memory owner means
|
||
// another path (recovery, sign-out) owns cleanup.
|
||
const liveSpaceId = cachedSpaceId;
|
||
if (liveSpaceId && liveSpaceId !== id) {
|
||
await writeStoredValue(SPACE_KEY, liveSpaceId);
|
||
}
|
||
} catch {
|
||
// Roll back the claim and heal durable state: a concurrent recovery may
|
||
// have persisted the rolled-back id after reading it, which would leave
|
||
// restart opening a Space the live session is not using. A newer
|
||
// overlapping selection owns both by now, so only heal a claim we hold.
|
||
if (cachedSpaceId === id && spaceSelectionGeneration === claimGeneration) {
|
||
cachedSpaceId = previousSpaceId;
|
||
bumpSpaceSelectionGeneration();
|
||
if (previousSpaceId) await writeStoredValue(SPACE_KEY, previousSpaceId);
|
||
}
|
||
return false;
|
||
}
|
||
await resumeLiveNotifications(currentApiBase(), await loadSessionToken(), id).catch(
|
||
() => undefined,
|
||
);
|
||
return true;
|
||
}
|
||
|
||
export function selectedSpaceId(): string | null {
|
||
return cachedSpaceId || null;
|
||
}
|
||
|
||
/** Keep requests usable after the server deleted the selected Space but native
|
||
* storage could not replace it. Neutralize any same-endpoint rollback before
|
||
* writing the replacement selection so a cleanup failure cannot leave the new
|
||
* id beside a stale record that recoverSpaceRollback would prefer on restart. */
|
||
export async function adoptDeletedSpaceFallback(id: string): Promise<boolean> {
|
||
cachedSpaceId = id;
|
||
bumpSpaceSelectionGeneration();
|
||
// Neutralize any same-endpoint rollback before writing SPACE_KEY. Writing the
|
||
// selection first can leave it beside a stale record that recoverSpaceRollback
|
||
// would prefer on restart if later cleanup fails.
|
||
const rollbackNeutralized =
|
||
(await clearStoredValue(SPACE_ROLLBACK_KEY)) || (await saveSpaceRollback(id));
|
||
if (rollbackNeutralized && (await writeStoredValue(SPACE_KEY, id))) {
|
||
// SPACE_KEY is authoritative; drop a rollback we may have written only to
|
||
// overwrite a stale record (best-effort).
|
||
await clearStoredValue(SPACE_ROLLBACK_KEY);
|
||
await resumeLiveNotifications(currentApiBase(), await loadSessionToken(), id).catch(
|
||
() => undefined,
|
||
);
|
||
return true;
|
||
}
|
||
// Clear before saving recovery: an empty selection lets startup resolve the
|
||
// server default even when the recovery record cannot be written.
|
||
const staleSelectionCleared = await clearStoredValue(SPACE_KEY);
|
||
const recoverySaved = await saveSpaceRollback(id);
|
||
await resumeLiveNotifications(currentApiBase(), await loadSessionToken(), id).catch(
|
||
() => undefined,
|
||
);
|
||
// Only treat a cleared selection as durable success when no same-endpoint
|
||
// rollback remains to override it after restart.
|
||
return recoverySaved || (staleSelectionCleared && (await clearStoredValue(SPACE_ROLLBACK_KEY)));
|
||
}
|
||
|
||
export async function selectInitialSpace(id: string) {
|
||
if (selectedSpaceId()) return true;
|
||
return selectSpace(id);
|
||
}
|
||
|
||
async function clearSpace(): Promise<boolean> {
|
||
const spaceCleared = await clearStoredValue(SPACE_KEY);
|
||
const rollbackCleared = await clearStoredValue(SPACE_ROLLBACK_KEY);
|
||
if (!spaceCleared || !rollbackCleared) return false;
|
||
cachedSpaceId = "";
|
||
bumpSpaceSelectionGeneration();
|
||
return true;
|
||
}
|
||
|
||
async function clearStoredValue(key: string): Promise<boolean> {
|
||
try {
|
||
await SecureStore.deleteItemAsync(key);
|
||
return true;
|
||
} catch {
|
||
return writeStoredValue(key, "");
|
||
}
|
||
}
|
||
|
||
async function writeStoredValue(key: string, value: string): Promise<boolean> {
|
||
try {
|
||
await SecureStore.setItemAsync(key, value);
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function snapshotSpace(): Promise<{ ok: true; value: string } | { ok: false }> {
|
||
if (cachedSpaceId) return { ok: true, value: cachedSpaceId };
|
||
try {
|
||
return { ok: true, value: (await SecureStore.getItemAsync(SPACE_KEY)) ?? "" };
|
||
} catch {
|
||
return { ok: false };
|
||
}
|
||
}
|
||
|
||
/** Clears session + space for an endpoint change. Restores both if either wipe fails. */
|
||
async function clearCredentialsForEndpointChange(): Promise<
|
||
{ ok: true; previousToken: string; previousSpace: string } | { ok: false; result: EndpointResult }
|
||
> {
|
||
const previousToken = await snapshotSessionToken();
|
||
const previousSpace = await snapshotSpace();
|
||
if (!previousToken.ok || !previousSpace.ok) {
|
||
return {
|
||
ok: false,
|
||
result: { ok: false, error: t("Could not clear the previous server session") },
|
||
};
|
||
}
|
||
const rollbackReady = previousSpace.value
|
||
? await saveSpaceRollback(previousSpace.value)
|
||
: await clearStoredValue(SPACE_ROLLBACK_KEY);
|
||
if (!rollbackReady) {
|
||
return {
|
||
ok: false,
|
||
result: { ok: false, error: t("Could not clear the previous server session") },
|
||
};
|
||
}
|
||
const sessionCleared = await clearSessionToken();
|
||
cachedSpaceId = "";
|
||
bumpSpaceSelectionGeneration();
|
||
const spaceCleared = await clearStoredValue(SPACE_KEY);
|
||
if (sessionCleared && spaceCleared) {
|
||
return { ok: true, previousToken: previousToken.value, previousSpace: previousSpace.value };
|
||
}
|
||
|
||
await restoreCredentials(previousToken.value, previousSpace.value);
|
||
return {
|
||
ok: false,
|
||
result: { ok: false, error: t("Could not clear the previous server session") },
|
||
};
|
||
}
|
||
|
||
async function restoreCredentials(previousToken: string, previousSpace: string) {
|
||
if (previousToken) await restoreSessionToken(previousToken);
|
||
if (previousSpace) {
|
||
cachedSpaceId = previousSpace;
|
||
bumpSpaceSelectionGeneration();
|
||
try {
|
||
await SecureStore.setItemAsync(SPACE_KEY, previousSpace);
|
||
await clearStoredValue(SPACE_ROLLBACK_KEY);
|
||
} catch {
|
||
// The endpoint-bound rollback record restores this selection after restart.
|
||
}
|
||
}
|
||
if (previousToken) {
|
||
await resumeLiveNotifications(currentApiBase(), previousToken, previousSpace).catch(
|
||
() => undefined,
|
||
);
|
||
}
|
||
}
|
||
|
||
async function saveSpaceRollback(spaceId: string): Promise<boolean> {
|
||
return writeStoredValue(
|
||
SPACE_ROLLBACK_KEY,
|
||
JSON.stringify({ apiBase: currentApiBase(), spaceId }),
|
||
);
|
||
}
|
||
|
||
async function recoverSpaceRollback(apiBase: string) {
|
||
const stored = await SecureStore.getItemAsync(SPACE_ROLLBACK_KEY);
|
||
if (!stored) return;
|
||
let parsed: unknown;
|
||
try {
|
||
parsed = JSON.parse(stored);
|
||
} catch {
|
||
await clearStoredValue(SPACE_ROLLBACK_KEY);
|
||
return;
|
||
}
|
||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||
await clearStoredValue(SPACE_ROLLBACK_KEY);
|
||
return;
|
||
}
|
||
const rollback = parsed as { apiBase?: unknown; spaceId?: unknown };
|
||
if (rollback.apiBase !== apiBase || typeof rollback.spaceId !== "string" || !rollback.spaceId) {
|
||
await clearStoredValue(SPACE_ROLLBACK_KEY);
|
||
return;
|
||
}
|
||
cachedSpaceId = rollback.spaceId;
|
||
bumpSpaceSelectionGeneration();
|
||
if (await writeStoredValue(SPACE_KEY, rollback.spaceId)) {
|
||
await clearStoredValue(SPACE_ROLLBACK_KEY);
|
||
return;
|
||
}
|
||
// Could not replace the selection yet. Drop the deleted id so startup RPCs
|
||
// are not scoped to an inaccessible Space; keep the recovery record.
|
||
await clearStoredValue(SPACE_KEY);
|
||
}
|
||
|
||
export async function saveApiBase(input: string): Promise<EndpointResult> {
|
||
const parsed = normalizeApiBase(input);
|
||
if (!parsed.ok) return parsed;
|
||
if (parsed.url === defaultApiBase()) return resetApiBase();
|
||
const previous = currentApiBase();
|
||
let cleared: { previousToken: string; previousSpace: string } | undefined;
|
||
if (parsed.url !== previous) {
|
||
const result = await clearCredentialsForEndpointChange();
|
||
if (!result.ok) return result.result;
|
||
cleared = result;
|
||
}
|
||
try {
|
||
await SecureStore.setItemAsync(ENDPOINT_KEY, parsed.url);
|
||
} catch {
|
||
if (cleared) await restoreCredentials(cleared.previousToken, cleared.previousSpace);
|
||
return { ok: false, error: t("Could not save the server URL") };
|
||
}
|
||
cachedApiBase = parsed.url;
|
||
await clearStoredValue(SPACE_ROLLBACK_KEY);
|
||
return parsed;
|
||
}
|
||
|
||
export async function resetApiBase(): Promise<EndpointResult> {
|
||
const previous = currentApiBase();
|
||
const url = defaultApiBase();
|
||
let cleared: { previousToken: string; previousSpace: string } | undefined;
|
||
if (url !== previous) {
|
||
const result = await clearCredentialsForEndpointChange();
|
||
if (!result.ok) return result.result;
|
||
cleared = result;
|
||
}
|
||
try {
|
||
await SecureStore.deleteItemAsync(ENDPOINT_KEY);
|
||
} catch {
|
||
if (cleared) {
|
||
await restoreCredentials(cleared.previousToken, cleared.previousSpace);
|
||
return { ok: false, error: t("Could not clear the custom server URL") };
|
||
}
|
||
}
|
||
cachedApiBase = url;
|
||
await clearStoredValue(SPACE_ROLLBACK_KEY);
|
||
return { ok: true, url };
|
||
}
|
||
|
||
export async function authHeaders(
|
||
spaceId: string | null = selectedSpaceId(),
|
||
): Promise<Record<string, string>> {
|
||
const token = await loadSessionToken();
|
||
return {
|
||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||
...(spaceId ? { "x-rakazo-space-id": spaceId } : {}),
|
||
};
|
||
}
|
||
|
||
export type ApiRequestContext = {
|
||
apiBase: string;
|
||
headers: Record<string, string>;
|
||
};
|
||
|
||
export async function captureApiRequestContext(): Promise<ApiRequestContext> {
|
||
const apiBase = currentApiBase();
|
||
const headers = await authHeaders(selectedSpaceId());
|
||
if (apiBase !== currentApiBase()) {
|
||
throw new Error(t("The server changed while starting the request"));
|
||
}
|
||
return { apiBase, headers };
|
||
}
|
||
|
||
async function authenticateWithEmail(
|
||
action: "sign-in" | "sign-up",
|
||
input: { email: string; password: string; name?: string },
|
||
) {
|
||
const { response, body } = await fetchMobileJson<unknown>(
|
||
`${currentApiBase()}/api/auth/${action}/email`,
|
||
{
|
||
method: "POST",
|
||
headers: { "content-type": "application/json", origin: "rakazo://" },
|
||
body: JSON.stringify(input),
|
||
},
|
||
{},
|
||
);
|
||
if (!response.ok) {
|
||
throw new Error(responseErrorMessage(body, `Could not ${action.replace("-", " ")}`));
|
||
}
|
||
const token = tokenFromAuthResponse(response, body);
|
||
if (action === "sign-up" && signupRequiresEmailVerification(body))
|
||
return { verificationRequired: true };
|
||
if (!token)
|
||
throw new Error(
|
||
t(
|
||
action === "sign-in"
|
||
? "Sign-in did not return a session"
|
||
: "Sign-up did not return a session",
|
||
),
|
||
);
|
||
if (!(await clearSpace())) throw new Error(t("Could not clear the previous space"));
|
||
await saveSessionToken(token);
|
||
return { verificationRequired: false };
|
||
}
|
||
|
||
export function signIn(email: string, password: string) {
|
||
return authenticateWithEmail("sign-in", { email, password });
|
||
}
|
||
|
||
export function signUp(email: string, password: string, name: string) {
|
||
return authenticateWithEmail("sign-up", { email, password, name });
|
||
}
|
||
|
||
export type PasswordResetCapabilities = { passwordReset: boolean; resetUrl: string | null };
|
||
|
||
export async function passwordResetCapabilities(): Promise<PasswordResetCapabilities> {
|
||
const { response, body } = await fetchMobileJson<PasswordResetCapabilities>(
|
||
`${currentApiBase()}/api/auth/capabilities`,
|
||
{ headers: { origin: "rakazo://" } },
|
||
{ passwordReset: false, resetUrl: null },
|
||
);
|
||
if (!response.ok) throw new Error("Could not load password recovery settings");
|
||
return body;
|
||
}
|
||
|
||
export async function requestPasswordReset(email: string, redirectTo: string): Promise<void> {
|
||
const { response, body } = await fetchMobileJson<unknown>(
|
||
`${currentApiBase()}/api/auth/request-password-reset`,
|
||
{
|
||
method: "POST",
|
||
headers: { "content-type": "application/json", origin: "rakazo://" },
|
||
body: JSON.stringify({ email, redirectTo }),
|
||
},
|
||
{},
|
||
);
|
||
if (!response.ok) throw new Error(responseErrorMessage(body, t("Could not send reset email")));
|
||
}
|
||
|
||
export async function changePassword(currentPassword: string, newPassword: string): Promise<void> {
|
||
const { response, body } = await fetchMobileJson<unknown>(
|
||
`${currentApiBase()}/api/auth/change-password`,
|
||
{
|
||
method: "POST",
|
||
headers: {
|
||
"content-type": "application/json",
|
||
origin: "rakazo://",
|
||
...(await authHeaders()),
|
||
},
|
||
body: JSON.stringify({ currentPassword, newPassword, revokeOtherSessions: true }),
|
||
},
|
||
{},
|
||
);
|
||
if (!response.ok) throw new Error(responseErrorMessage(body, t("Could not change password")));
|
||
}
|
||
|
||
async function fetchMobileJson<T>(
|
||
input: Parameters<typeof fetch>[0],
|
||
init: RequestInit,
|
||
invalidJsonFallback?: T,
|
||
): Promise<{ response: Response; body: T }> {
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(new Error("Request timed out")), RPC_TIMEOUT_MS);
|
||
try {
|
||
const response = await withAbort(
|
||
fetch(input, { ...init, signal: controller.signal }),
|
||
controller.signal,
|
||
);
|
||
try {
|
||
const body = await readBoundedJsonResponse<T>(
|
||
response,
|
||
MAX_MOBILE_AUTH_RESPONSE_BYTES,
|
||
controller.signal,
|
||
);
|
||
return { response, body };
|
||
} catch (error) {
|
||
if (invalidJsonFallback !== undefined && error instanceof SyntaxError) {
|
||
return { response, body: invalidJsonFallback };
|
||
}
|
||
throw error;
|
||
}
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
export async function signOut() {
|
||
await rpc("notifications/unregisterPush").catch(() => undefined);
|
||
const headers = await authHeaders();
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), RPC_TIMEOUT_MS);
|
||
try {
|
||
await withAbort(
|
||
fetch(`${currentApiBase()}/api/auth/sign-out`, {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json", origin: "rakazo://", ...headers },
|
||
signal: controller.signal,
|
||
}),
|
||
controller.signal,
|
||
).catch(() => undefined);
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
const sessionCleared = await clearSessionToken();
|
||
const spaceCleared = await clearSpace();
|
||
if (!sessionCleared || !spaceCleared) throw new Error(t("Could not clear the local session"));
|
||
}
|
||
|
||
function withAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
||
if (signal.aborted) return Promise.reject(signal.reason ?? new Error("Request timed out"));
|
||
return new Promise<T>((resolve, reject) => {
|
||
const onAbort = () => reject(signal.reason ?? new Error("Request timed out"));
|
||
signal.addEventListener("abort", onAbort, { once: true });
|
||
promise.then(
|
||
(value) => {
|
||
signal.removeEventListener("abort", onAbort);
|
||
resolve(value);
|
||
},
|
||
(error: unknown) => {
|
||
signal.removeEventListener("abort", onAbort);
|
||
reject(error);
|
||
},
|
||
);
|
||
});
|
||
}
|
||
|
||
export async function deleteAccount(password: string) {
|
||
await rpc("notifications/unregisterPush").catch(() => undefined);
|
||
const { response, body } = await fetchMobileJson<unknown>(
|
||
`${currentApiBase()}/api/auth/delete-user`,
|
||
{
|
||
method: "POST",
|
||
headers: {
|
||
"content-type": "application/json",
|
||
origin: "rakazo://",
|
||
...(await authHeaders()),
|
||
},
|
||
body: JSON.stringify({ password }),
|
||
},
|
||
{},
|
||
);
|
||
if (!response.ok) {
|
||
throw new Error(responseErrorMessage(body, t("Could not delete account")));
|
||
}
|
||
await clearSessionToken();
|
||
await clearSpace();
|
||
}
|
||
|
||
export async function rpc<T>(
|
||
proc: string,
|
||
body: unknown = {},
|
||
options: {
|
||
signal?: AbortSignal;
|
||
timeoutMs?: number | null;
|
||
requestContext?: ApiRequestContext;
|
||
skipSpaceAuthRecovery?: boolean;
|
||
} = {},
|
||
): Promise<T> {
|
||
const requestSpaceGeneration = spaceSelectionGeneration;
|
||
const uses = aiDataUsesForProcedure(proc, body);
|
||
const consentContext =
|
||
options.requestContext ?? (uses.length ? await captureApiRequestContext() : undefined);
|
||
await ensureAiDataConsent({
|
||
uses,
|
||
status: () =>
|
||
rpc(
|
||
"aiConsent/status",
|
||
{ uses, ...aiConsentTarget(body) },
|
||
{ requestContext: consentContext },
|
||
),
|
||
prompt: promptAiConsent,
|
||
allow: (input) => rpc("aiConsent/allow", input, { requestContext: consentContext }),
|
||
});
|
||
// Abort with an explicit reason so every consumer of the signal (the fetch, the bounded body
|
||
// read, and nested recovery calls that share this signal) reports the same cause.
|
||
const controller = new AbortController();
|
||
const cancel = () => controller.abort(options.signal?.reason ?? new Error("Request canceled"));
|
||
if (options.signal?.aborted) cancel();
|
||
else options.signal?.addEventListener("abort", cancel, { once: true });
|
||
const timer =
|
||
options.timeoutMs === null
|
||
? undefined
|
||
: setTimeout(
|
||
() => controller.abort(new Error("Request timed out")),
|
||
options.timeoutMs ?? RPC_TIMEOUT_MS,
|
||
);
|
||
const abortReason = (error: unknown) =>
|
||
controller.signal.aborted ? (controller.signal.reason ?? error) : error;
|
||
// Bind recovery to the Space + selection epoch this request was sent with:
|
||
// a 401 arriving after the user switched Spaces — including A → B → A —
|
||
// belongs to a stale request and must not touch the current selection.
|
||
const requestHeaders = consentContext?.headers ?? (await authHeaders());
|
||
const requestSpaceId = requestHeaders["x-rakazo-space-id"];
|
||
try {
|
||
let res: Response;
|
||
try {
|
||
res = await fetch(`${consentContext?.apiBase ?? currentApiBase()}/rpc/${proc}`, {
|
||
method: "POST",
|
||
headers: {
|
||
"content-type": "application/json",
|
||
origin: "rakazo://",
|
||
...requestHeaders,
|
||
},
|
||
body: JSON.stringify({ json: body }),
|
||
signal: controller.signal,
|
||
});
|
||
} catch (error) {
|
||
// The native fetch reports an aborted request with an implementation detail
|
||
// ("FetchRequestCanceledException"); say what happened instead.
|
||
throw abortReason(error);
|
||
}
|
||
if (proc === "aiConsent/status" && res.status === 404) {
|
||
cancelResponseBody(res);
|
||
throw new Error(t("Update your server to use AI data sharing in this mobile version."));
|
||
}
|
||
const parsed = await readBoundedJsonResponse<{ json?: T; error?: { message?: string } }>(
|
||
res,
|
||
MAX_MOBILE_RPC_RESPONSE_BYTES,
|
||
controller.signal,
|
||
).catch((error: unknown) => {
|
||
throw abortReason(error);
|
||
});
|
||
if (!res.ok || parsed.error) {
|
||
const message = parsed.error?.message ?? `rpc ${proc} failed`;
|
||
const unauthorized = res.status === 401 || /unauthorized/i.test(message);
|
||
// After a delete where SecureStore could not clear the stale id, restart
|
||
// reloads it and the first RPCs 401. Probe once without a Space header:
|
||
// success means the selection was inaccessible (clear it); failure means
|
||
// the session itself is bad (restore the selection so a later sign-in
|
||
// keeps the user's Space). Never replay a mutation against the default
|
||
// Space — only safe reads may retry as themselves; other procs probe
|
||
// with spaces/list, then fail the original call.
|
||
const previousSpaceId = selectedSpaceId();
|
||
// Clear stale selection records, then re-persist a Space selected while
|
||
// cleanup was in flight: check-then-clear cannot be atomic on
|
||
// SecureStore, so reconcile afterwards instead of trusting the check.
|
||
const clearStaleSpaceSelection = async () => {
|
||
await clearStoredValue(SPACE_KEY);
|
||
await clearStoredValue(SPACE_ROLLBACK_KEY);
|
||
const reselected = selectedSpaceId();
|
||
if (!reselected) return;
|
||
// Own the reconcile write by generation: a newer selection that
|
||
// persists between snapshot and write must not be overwritten by
|
||
// this stale id, and a write that lands stale heals once to live.
|
||
const writeGeneration = spaceSelectionGeneration;
|
||
await writeStoredValue(SPACE_KEY, reselected);
|
||
if (spaceSelectionGeneration !== writeGeneration) {
|
||
const live = selectedSpaceId();
|
||
if (live) await writeStoredValue(SPACE_KEY, live);
|
||
}
|
||
};
|
||
if (
|
||
unauthorized &&
|
||
previousSpaceId &&
|
||
requestSpaceId &&
|
||
selectedSpaceId() === requestSpaceId &&
|
||
spaceSelectionGeneration === requestSpaceGeneration &&
|
||
!options.requestContext &&
|
||
!options.skipSpaceAuthRecovery
|
||
) {
|
||
cachedSpaceId = "";
|
||
bumpSpaceSelectionGeneration();
|
||
const retrySameProc = SPACE_AUTH_RECOVERY_SAFE_PROCS.has(proc);
|
||
// Share the original deadline/cancellation with recovery calls instead
|
||
// of starting a second full timeout behind the first request.
|
||
const recoveryOptions: {
|
||
signal?: AbortSignal;
|
||
timeoutMs?: number | null;
|
||
requestContext?: ApiRequestContext;
|
||
skipSpaceAuthRecovery?: boolean;
|
||
} = {
|
||
...options,
|
||
timeoutMs: null,
|
||
signal: controller.signal,
|
||
skipSpaceAuthRecovery: true,
|
||
};
|
||
try {
|
||
if (retrySameProc) {
|
||
const result = await rpc<T>(proc, body, recoveryOptions);
|
||
// A Space selected while the retry was in flight already owns both
|
||
// the in-memory and durable selection; leave it alone.
|
||
if (!selectedSpaceId()) await clearStaleSpaceSelection();
|
||
return result;
|
||
}
|
||
await rpc("spaces/list", {}, recoveryOptions);
|
||
} catch (retryError) {
|
||
if (!selectedSpaceId()) {
|
||
cachedSpaceId = previousSpaceId;
|
||
bumpSpaceSelectionGeneration();
|
||
}
|
||
throw retryError;
|
||
}
|
||
if (!selectedSpaceId()) await clearStaleSpaceSelection();
|
||
throw new Error(message);
|
||
}
|
||
throw new Error(message);
|
||
}
|
||
return parsed.json as T;
|
||
} finally {
|
||
if (timer) clearTimeout(timer);
|
||
options.signal?.removeEventListener("abort", cancel);
|
||
}
|
||
}
|
||
|
||
export type MobileBot = Pick<
|
||
Bot,
|
||
| "id"
|
||
| "name"
|
||
| "preview"
|
||
| "title"
|
||
| "color"
|
||
| "notifyOnFinish"
|
||
| "threadId"
|
||
| "pinned"
|
||
| "status"
|
||
| "sectionId"
|
||
| "archivedAt"
|
||
| "unread"
|
||
| "updatedAt"
|
||
| "computerMode"
|
||
| "modelProvider"
|
||
| "modelId"
|
||
| "thinkingLevel"
|
||
> &
|
||
Partial<Pick<Bot, "parentBotId" | "spaceId" | "kind">>;
|
||
|
||
export type MobileBotSection = BotSection;
|
||
|
||
export type MobileMe = Pick<
|
||
Me,
|
||
| "name"
|
||
| "email"
|
||
| "spaceId"
|
||
| "defaultProvider"
|
||
| "defaultModel"
|
||
| "needsModel"
|
||
| "modelsManaged"
|
||
| "avatarStyle"
|
||
| "isDeploymentOwner"
|
||
>;
|
||
|
||
export type MobileModel = ModelCatalogEntry;
|
||
|
||
export type MobileModelCredential = ModelCredential;
|
||
|
||
export type MobileMessage = {
|
||
id: string;
|
||
threadId?: string;
|
||
seq?: number;
|
||
runId?: string;
|
||
role: "user" | "bot" | "system";
|
||
botId?: string;
|
||
replyToMessageId?: string;
|
||
replyQuote?: string;
|
||
createdAt?: string;
|
||
blocks: MessageBlock[];
|
||
};
|
||
|
||
export type MobileGroup = Pick<
|
||
Group,
|
||
| "id"
|
||
| "name"
|
||
| "preview"
|
||
| "pinned"
|
||
| "sectionId"
|
||
| "archivedAt"
|
||
| "unread"
|
||
| "updatedAt"
|
||
| "members"
|
||
> &
|
||
Partial<Pick<Group, "spaceId">>;
|
||
|
||
export type MobileSpace = Space;
|
||
export type MobileSpaceNavigation = SpaceNavigation;
|
||
|
||
export type MobileSnapshot = {
|
||
botId?: string;
|
||
groupId?: string;
|
||
groupName?: string;
|
||
threadId: string;
|
||
cursor?: number;
|
||
messages: MobileMessage[];
|
||
olderCursor: number | null;
|
||
run: { id: string; botId?: string; status: string; error?: string | null } | null;
|
||
activeRuns?: Array<{ id: string; botId?: string; status: string }>;
|
||
members?: MobileGroup["members"];
|
||
computer?: {
|
||
kind?: string;
|
||
state: string;
|
||
controlHolder: string;
|
||
screenAvailable: boolean;
|
||
takeoverRequested?: boolean;
|
||
mode: ComputerMode;
|
||
busyBotName: string | null;
|
||
};
|
||
};
|
||
|
||
export function shouldApplyMobileThreadRefresh(input: {
|
||
requestEpoch: number;
|
||
currentEpoch: number;
|
||
targetBotId: string | undefined;
|
||
targetGroupId: string | undefined;
|
||
activeBotId: string | undefined;
|
||
activeGroupId: string | undefined;
|
||
}) {
|
||
return (
|
||
input.requestEpoch === input.currentEpoch &&
|
||
input.targetBotId === input.activeBotId &&
|
||
input.targetGroupId === input.activeGroupId
|
||
);
|
||
}
|
||
|
||
export type MobileMessagePage = ThreadHistory<MobileMessage>;
|
||
|
||
export function mergeMobileSnapshot(
|
||
prev: MobileSnapshot | null,
|
||
next: MobileSnapshot,
|
||
preserveLoadedHistory = false,
|
||
): MobileSnapshot {
|
||
return mergeThreadHistory(prev, next, preserveLoadedHistory);
|
||
}
|
||
|
||
export function prependMobileMessagePage(
|
||
prev: MobileSnapshot | null,
|
||
page: MobileMessagePage,
|
||
): MobileSnapshot | null {
|
||
return prependThreadHistoryPage(prev, page);
|
||
}
|
||
|
||
const MESSAGING_PROVIDER_LABELS: Record<string, string> = {
|
||
sendblue: "iMessage",
|
||
slack: "Slack",
|
||
whatsapp: "WhatsApp",
|
||
telegram: "Telegram",
|
||
lark: "Feishu",
|
||
};
|
||
|
||
export function messagingProviderLabel(provider: string, transport?: string): string {
|
||
if (provider === "sendblue" && ["iMessage", "SMS", "RCS"].includes(transport ?? "")) {
|
||
return transport!;
|
||
}
|
||
return MESSAGING_PROVIDER_LABELS[provider] ?? provider;
|
||
}
|
||
|
||
export function copyableMobileMessageText(message: MobileMessage): string {
|
||
return message.blocks
|
||
.map((block) => {
|
||
if (block.kind === "channel_message") {
|
||
return `${messagingProviderLabel(block.provider, block.transport)} · ${block.fromLabel}: ${block.text}`;
|
||
}
|
||
if (block.kind === "text" || block.kind === "progress" || block.kind === "ask")
|
||
return block.text;
|
||
return "";
|
||
})
|
||
.filter(Boolean)
|
||
.join("\n")
|
||
.trim();
|
||
}
|
||
|
||
export type MobileAttachment = {
|
||
kind: "image" | "video" | "audio" | "file";
|
||
artifactId: string;
|
||
name: string;
|
||
mimeType: string;
|
||
size?: number;
|
||
};
|
||
|
||
/** Files a message carries, including the outputs of a finished media card, as tappable rows. */
|
||
export function messageAttachments(blocks: readonly MessageBlock[]): MobileAttachment[] {
|
||
return blocks.flatMap((block): MobileAttachment[] => {
|
||
if (block.kind === "image") return [{ ...block, kind: "image" }];
|
||
if (block.kind === "video" || block.kind === "audio" || block.kind === "file") return [block];
|
||
if (block.kind === "media") return block.outputs;
|
||
return [];
|
||
});
|
||
}
|
||
|
||
export function blockText(message: MobileMessage) {
|
||
return message.blocks
|
||
.map((block) => {
|
||
if (block.kind === "channel_message") {
|
||
return `${messagingProviderLabel(block.provider, block.transport)} · ${block.fromLabel}: ${block.text}`;
|
||
}
|
||
if (block.kind === "cloud_agent")
|
||
return `${block.title}: ${block.status}${block.prUrl ? ` ${block.prUrl}` : ""}`;
|
||
if (block.kind === "subagent") {
|
||
return `${block.name ?? "subagent"}: ${block.result || block.progress || block.task || ""}`;
|
||
}
|
||
if (block.kind === "child_bot") {
|
||
return `${block.status === "archived" ? "Archived" : block.status === "deleted" ? "Deleted" : "Bot"} ${block.name ?? ""}`;
|
||
}
|
||
// Choice cards are answered on the web; here they read as the question and its answer.
|
||
if (block.kind === "choice") {
|
||
const answer =
|
||
block.answerText ?? block.options.find((option) => option.id === block.answerId)?.label;
|
||
return answer ? `${block.question}\n${answer}` : block.question;
|
||
}
|
||
if (block.kind === "chart") return `[chart: ${block.name ?? "chart"}]`;
|
||
if (block.kind === "event") return block.name;
|
||
if (block.kind === "autopilot") return block.text ?? block.goal;
|
||
if (block.kind === "card") {
|
||
const header = block.sections?.find((part) => part.type === "header");
|
||
return header?.type === "header"
|
||
? header.title
|
||
: (block.lines ?? []).map((line) => `${line.k}: ${line.v}`).join("\n");
|
||
}
|
||
if (block.kind === "image") return `[image: ${block.name ?? "attachment"}]`;
|
||
if (block.kind === "file") {
|
||
return `[file: ${block.name ?? "attachment"}${block.size ? ` (${block.size} bytes)` : ""}]`;
|
||
}
|
||
if (block.kind === "video" || block.kind === "audio") {
|
||
return `[${block.kind}: ${block.name ?? "attachment"}]`;
|
||
}
|
||
if (block.kind === "media") return `${block.title}: ${block.status}`;
|
||
if (block.kind === "steps") {
|
||
return (block.steps ?? [])
|
||
.map((step) => `${step.label}${step.count > 1 ? ` ×${step.count}` : ""}`)
|
||
.join(" · ");
|
||
}
|
||
return ("text" in block ? block.text : "state" in block ? block.state : "") ?? "";
|
||
})
|
||
.filter(Boolean)
|
||
.join("\n");
|
||
}
|
||
|
||
type ThreadEvent = {
|
||
id?: string;
|
||
botId?: string;
|
||
type: string;
|
||
seq?: number;
|
||
runId?: string;
|
||
createdAt?: string;
|
||
payload?: Record<string, unknown>;
|
||
};
|
||
|
||
export async function subscribeThread(
|
||
target: { botId: string } | { groupId: string },
|
||
cursor: number,
|
||
onEvent: (event: ThreadEvent) => void,
|
||
signal: AbortSignal,
|
||
) {
|
||
const res = await fetch(`${currentApiBase()}/rpc/threads/subscribe`, {
|
||
method: "POST",
|
||
headers: {
|
||
"content-type": "application/json",
|
||
accept: "text/event-stream",
|
||
origin: "rakazo://",
|
||
...(await authHeaders()),
|
||
},
|
||
body: JSON.stringify({ json: { ...target, cursor } }),
|
||
signal,
|
||
});
|
||
if (!res.ok || !res.body) throw new Error(`rpc threads/subscribe failed (${res.status})`);
|
||
const reader = res.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let buffer = "";
|
||
while (!signal.aborted) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const chunks = buffer.split("\n\n");
|
||
buffer = chunks.pop() ?? "";
|
||
for (const chunk of chunks) {
|
||
const data = chunk
|
||
.split("\n")
|
||
.filter((line) => line.startsWith("data:"))
|
||
.map((line) => line.slice(5).trim())
|
||
.join("");
|
||
if (!data || data === "[DONE]") continue;
|
||
try {
|
||
const parsed = JSON.parse(data) as { json?: ThreadEvent; error?: { message?: string } };
|
||
if (parsed.json?.type) onEvent(parsed.json);
|
||
} catch {
|
||
// ignore keepalives and partial frames
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
export function applyMobileThreadEvent(
|
||
prev: MobileSnapshot | null,
|
||
event: ThreadEvent,
|
||
): MobileSnapshot | null {
|
||
if (!prev) return prev;
|
||
if (event.type === "thread.cleared") {
|
||
return {
|
||
...prev,
|
||
cursor: event.seq,
|
||
messages: [],
|
||
olderCursor: null,
|
||
run: null,
|
||
activeRuns: [],
|
||
};
|
||
}
|
||
if (event.type === "run.waiting_input" || event.type === "computer.takeover.requested") {
|
||
const status = event.type === "run.waiting_input" ? "waiting_input" : "waiting_takeover";
|
||
const progressId = progressMessageId(event);
|
||
// Waiting pauses drop live progress server-side; clear a leftover bubble so
|
||
// the waiting footer is not hidden behind a stale "Working…" row.
|
||
const messages = prev.messages.filter((message) => message.id !== progressId);
|
||
const progressCleared = messages.length !== prev.messages.length;
|
||
const runId = event.runId;
|
||
const knownInRun = Boolean(runId && prev.run?.id === runId);
|
||
const knownInActive = Boolean(
|
||
runId && prev.activeRuns?.some((candidate) => candidate.id === runId),
|
||
);
|
||
// Peer bot_message runs are omitted from snapshots while busy; the first wait
|
||
// event is how an open thread learns they need ask/takeover UI.
|
||
const needsInsert = Boolean(runId) && !knownInRun && !knownInActive;
|
||
const runChanged = Boolean(knownInRun && prev.run && prev.run.status !== status);
|
||
const activeRunChanged = Boolean(
|
||
knownInActive &&
|
||
prev.activeRuns?.some((candidate) => candidate.id === runId && candidate.status !== status),
|
||
);
|
||
const computer =
|
||
event.type === "computer.takeover.requested" && prev.computer?.busyBotName
|
||
? { ...prev.computer, busyBotName: null }
|
||
: prev.computer;
|
||
const computerChanged = computer !== prev.computer;
|
||
const cursor = event.seq ?? prev.cursor;
|
||
if (!runChanged && !activeRunChanged && !progressCleared && !needsInsert && !computerChanged) {
|
||
return cursor === prev.cursor ? prev : { ...prev, cursor };
|
||
}
|
||
if (needsInsert && runId) {
|
||
const waitingRun = {
|
||
id: runId,
|
||
status,
|
||
...(event.botId ? { botId: event.botId } : {}),
|
||
};
|
||
const baseActive = prev.activeRuns ?? (prev.run ? [prev.run] : []);
|
||
const activeRuns = [...baseActive.filter((candidate) => candidate.id !== runId), waitingRun];
|
||
const promoteWaiting =
|
||
!prev.run ||
|
||
(prev.run.status !== "waiting_input" && prev.run.status !== "waiting_takeover");
|
||
return {
|
||
...prev,
|
||
cursor,
|
||
messages,
|
||
computer,
|
||
run: promoteWaiting ? waitingRun : prev.run,
|
||
activeRuns,
|
||
};
|
||
}
|
||
const run = runChanged && prev.run ? { ...prev.run, status } : prev.run;
|
||
const activeRuns = activeRunChanged
|
||
? prev.activeRuns?.map((candidate) =>
|
||
candidate.id === runId ? { ...candidate, status } : candidate,
|
||
)
|
||
: prev.activeRuns;
|
||
return { ...prev, cursor, run, activeRuns, messages, computer };
|
||
}
|
||
if (isRunTerminalEvent(event)) {
|
||
const activeRuns = prev.activeRuns?.filter((candidate) => candidate.id !== event.runId);
|
||
const failure = runFailureError(event);
|
||
const primaryEnded = prev.run?.id === event.runId ? prev.run : null;
|
||
// A group member run can fail while another is displayed; see reduceThreadSnapshot.
|
||
const endedRun =
|
||
primaryEnded ?? prev.activeRuns?.find((candidate) => candidate.id === event.runId) ?? null;
|
||
return {
|
||
...prev,
|
||
cursor: event.seq ?? prev.cursor,
|
||
messages: prev.messages.filter((message) => message.id !== progressMessageId(event)),
|
||
// A failed run stays in run so the thread can say why it stopped (see reduceThreadSnapshot).
|
||
run:
|
||
endedRun && failure
|
||
? { ...endedRun, status: "failed", error: failure }
|
||
: primaryEnded
|
||
? (activeRuns?.[0] ?? null)
|
||
: prev.run,
|
||
activeRuns,
|
||
};
|
||
}
|
||
if (event.type === "thread.progress") {
|
||
const progressId = progressMessageId(event);
|
||
const { previous, remaining } = takeLiveMessage(prev.messages, progressId);
|
||
const streaming: MobileMessage = {
|
||
id: progressId,
|
||
role: "bot",
|
||
blocks: reduceLiveMessageBlocks((previous?.blocks ?? []) as MessageBlock[], {
|
||
type: "progress",
|
||
payload: event.payload,
|
||
}),
|
||
...(event.botId ? { botId: event.botId } : {}),
|
||
...(event.runId ? { runId: event.runId } : {}),
|
||
};
|
||
return {
|
||
...prev,
|
||
cursor: event.seq ?? prev.cursor,
|
||
messages: [...remaining, streaming],
|
||
};
|
||
}
|
||
if (event.type === "agent.tool.called") {
|
||
const progressId = progressMessageId(event);
|
||
const { previous, remaining } = takeLiveMessage(prev.messages, progressId);
|
||
const streaming: MobileMessage = {
|
||
id: progressId,
|
||
role: "bot",
|
||
blocks: reduceLiveMessageBlocks((previous?.blocks ?? []) as MessageBlock[], {
|
||
type: "tool",
|
||
name: String(event.payload?.name ?? ""),
|
||
}),
|
||
...(event.botId ? { botId: event.botId } : {}),
|
||
...(event.runId ? { runId: event.runId } : {}),
|
||
};
|
||
return {
|
||
...prev,
|
||
cursor: event.seq ?? prev.cursor,
|
||
messages: [...remaining, streaming],
|
||
};
|
||
}
|
||
if (event.type === "agent.tool.completed" || event.type === "thread.computer") {
|
||
return { ...prev, cursor: event.seq ?? prev.cursor };
|
||
}
|
||
if (event.type === "thread.subagent") {
|
||
const agentId = String(event.payload?.agentId ?? event.id ?? "live");
|
||
const status = event.payload?.status;
|
||
const streaming: MobileMessage = {
|
||
id: `subagent:${agentId}`,
|
||
role: "bot",
|
||
...(event.botId ? { botId: event.botId } : {}),
|
||
...(event.runId ? { runId: event.runId } : {}),
|
||
blocks: [
|
||
{
|
||
kind: "subagent",
|
||
agentId,
|
||
name: String(event.payload?.name ?? "subagent"),
|
||
task: String(event.payload?.task ?? ""),
|
||
status: status === "completed" || status === "failed" ? status : "running",
|
||
progress: event.payload?.progress ? String(event.payload.progress) : undefined,
|
||
result: event.payload?.result ? String(event.payload.result) : undefined,
|
||
},
|
||
],
|
||
};
|
||
return {
|
||
...prev,
|
||
cursor: event.seq ?? prev.cursor,
|
||
messages: [...prev.messages.filter((message) => message.id !== streaming.id), streaming],
|
||
};
|
||
}
|
||
if (event.type === "thread.cloud_agent") {
|
||
return {
|
||
...prev,
|
||
cursor: event.seq ?? prev.cursor,
|
||
messages: updateCloudAgentMessages(prev.messages, event.payload ?? {}),
|
||
};
|
||
}
|
||
if (event.type === "thread.message.created" || event.type === "thread.message.updated") {
|
||
const { remaining } = takeLiveMessage(prev.messages, progressMessageId(event));
|
||
const next: MobileMessage = {
|
||
id: String(event.payload?.messageId ?? event.id ?? `msg:${event.seq ?? 0}`),
|
||
runId: event.runId ? String(event.runId) : undefined,
|
||
role: (event.payload?.role as MobileMessage["role"]) ?? "bot",
|
||
blocks: (event.payload?.blocks as MobileMessage["blocks"]) ?? [],
|
||
botId: event.botId ?? (event.payload?.botId ? String(event.payload.botId) : undefined),
|
||
replyToMessageId: event.payload?.replyToMessageId
|
||
? String(event.payload.replyToMessageId)
|
||
: undefined,
|
||
replyQuote: event.payload?.replyQuote ? String(event.payload.replyQuote) : undefined,
|
||
};
|
||
return {
|
||
...prev,
|
||
cursor: event.seq ?? prev.cursor,
|
||
messages: upsertMessageById(
|
||
remaining.filter(
|
||
(message) =>
|
||
!(
|
||
message.id.startsWith("subagent:") &&
|
||
next.blocks.some(
|
||
(block) => block.kind === "subagent" && message.id === `subagent:${block.agentId}`,
|
||
)
|
||
),
|
||
),
|
||
next,
|
||
),
|
||
};
|
||
}
|
||
return prev;
|
||
}
|
||
|
||
export {
|
||
apiBaseWarning,
|
||
defaultApiBase,
|
||
displayApiHost,
|
||
normalizeApiBase,
|
||
probeApiBase,
|
||
usesCustomApiBase,
|
||
} from "./endpoint";
|
||
export { loadSessionToken };
|