Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1 Co-authored-by: Claude <noreply@anthropic.com>
1193 lines
41 KiB
TypeScript
1193 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"
|
||
| "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 === "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 };
|