Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
258 changes: 258 additions & 0 deletions packages/ui/src/first-run/first-run-finish.firstload-chain.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
// @vitest-environment jsdom

/**
* Regression for the post-login first-load resolution chain (first-5 strike,
* FIRSTLOAD-REAL-2026-07-22). On staging the canary's post-token stall was a
* SERIAL chain: /api/v1/user status probe (~0.8s) → /api/v1/eliza/agents
* (~1.8s) → bind → cold agent-base /api/auth/me (~2.3s). Three structural
* invariants pin the fix (structural, not timing-based, so they cannot flake):
*
* 1. A stored bearer skips the /api/v1/user status probe entirely — the
* agents list IS the connectivity probe, and its result is REUSED as
* `knownAgents` for the bind (exactly one list fetch, zero status probes).
* 2. After `handleCloudLogin` lands a bearer, no post-login status re-probe
* runs — the token is the proof; the probe only runs when no token landed.
* 3. The bind warms the just-bound agent base with a fire-and-forget
* conversations fetch so the post-ready hydrate hits a warm container —
* and a client shim without that chat surface is a safe no-op.
*
* The stale-token degrade (list fails → status probe → login re-entry) is
* pinned too, so the fast path can never strand a revoked session.
*/

import { beforeEach, describe, expect, it, vi } from "vitest";
import type { FirstRunProfileDraft } from "./first-run";
import type { FirstRunFinishPorts } from "./first-run-finish";
import {
bindCloudAgent,
listOrAutoProvisionCloudAgent,
} from "./first-run-finish";

const SHARED_AGENT_BASE =
"https://staging.elizacloud.ai/api/v1/eliza/agents/cad3c071";

const RUNNING_AGENT = {
agent_id: "cad3c071",
agent_name: "Eliza",
status: "running",
created_at: "2026-07-01T00:00:00Z",
};

const clientStub = vi.hoisted(() => ({
selectOrProvisionCloudAgent: vi.fn(),
submitFirstRun: vi.fn(async () => {}),
setBaseUrl: vi.fn(),
setToken: vi.fn(),
getBaseUrl: vi.fn(() => ""),
createCloudCompatAgent: vi.fn(),
startCloudAgentHandoff: vi.fn(),
deleteSharedBridgeAgent: vi.fn(async () => ({ success: true })),
getCloudCompatAgents: vi.fn(),
getCloudStatus: vi.fn(async () => ({ connected: false })),
getRestAuthToken: vi.fn(() => null as string | null),
listConversations: vi.fn(async () => ({ conversations: [] })) as
| ReturnType<typeof vi.fn>
| undefined,
}));

const runCloudAgentHandoffStub = vi.hoisted(() => vi.fn());
const resumePendingCloudHandoffStub = vi.hoisted(() => vi.fn(() => true));
const savePersistedFirstRunCompleteStub = vi.hoisted(() => vi.fn());
const silentlyRepointToDedicatedStub = vi.hoisted(() => vi.fn());
const runAgentSessionRecoveryStub = vi.hoisted(() => vi.fn());
const removeAgentProfileStub = vi.hoisted(() => vi.fn());
const loadPersistedActiveServerStub = vi.hoisted(() =>
vi.fn<() => { kind: string; id?: string } | null>(() => null),
);

vi.mock("../api", () => ({ client: clientStub }));

vi.mock("../cloud/handoff/silent-repoint", () => ({
silentlyRepointToDedicated: silentlyRepointToDedicatedStub,
}));

vi.mock("../state/agent-session-recovery-runner", () => ({
runAgentSessionRecovery: runAgentSessionRecoveryStub,
}));

vi.mock("../cloud/handoff/run-cloud-agent-handoff", () => ({
runCloudAgentHandoff: runCloudAgentHandoffStub,
}));

vi.mock("../cloud/handoff/resume-pending-handoff", () => ({
resumePendingCloudHandoff: resumePendingCloudHandoffStub,
}));

vi.mock("../config/boot-config", () => ({
getBootConfig: () => ({
cloudApiBase: "https://staging.elizacloud.ai",
preferSharedCloudTier: true,
}),
}));

vi.mock("../state", () => ({
addAgentProfile: vi.fn(() => ({ id: "profile-1" })),
createPersistedActiveServer: vi.fn((v) => ({ label: "Eliza Cloud", ...v })),
loadPersistedActiveServer: loadPersistedActiveServerStub,
removeAgentProfile: removeAgentProfileStub,
savePersistedActiveServer: vi.fn(),
savePersistedFirstRunComplete: savePersistedFirstRunCompleteStub,
}));

vi.mock("./mobile-runtime-mode", async (importOriginal) => ({
...(await importOriginal<typeof import("./mobile-runtime-mode")>()),
persistMobileRuntimeModeForServerTarget: vi.fn(),
}));

function draft(): FirstRunProfileDraft {
return {
agentName: "Eliza",
runtime: "cloud",
localInference: "cloud-inference",
remoteApiBase: "",
remoteToken: "",
};
}

function ports(overrides: Partial<FirstRunFinishPorts> = {}): {
ports: FirstRunFinishPorts;
handleCloudLogin: ReturnType<typeof vi.fn>;
} {
const handleCloudLogin = vi.fn(async () => {});
return {
ports: {
uiLanguage: "en",
elizaCloudConnected: false,
handleCloudLogin,
setRuntimeState: vi.fn(),
setTab: vi.fn(),
completeFirstRun: vi.fn(),
onStatus: vi.fn(),
...overrides,
},
handleCloudLogin,
};
}

function storeStewardToken(token = "steward-jwt"): void {
window.localStorage.setItem("steward_session_token", token);
}

function stubSelection(): void {
clientStub.selectOrProvisionCloudAgent.mockResolvedValue({
agentId: "cad3c071",
agentName: "Eliza",
apiBase: SHARED_AGENT_BASE,
bridgeUrl: "https://cad3c071.elizacloud.ai",
requiresAgentPairing: false,
created: false,
});
}

beforeEach(() => {
vi.clearAllMocks();
window.localStorage.clear();
clientStub.listConversations = vi.fn(async () => ({ conversations: [] }));
clientStub.getCloudStatus.mockResolvedValue({ connected: false });
clientStub.getCloudCompatAgents.mockResolvedValue({
success: true,
data: [RUNNING_AGENT],
});
stubSelection();
});

describe("listOrAutoProvisionCloudAgent — no serial status probe before the agents list", () => {
it("with a stored bearer: skips getCloudStatus entirely, fetches the agents list ONCE, and reuses it as knownAgents for the bind", async () => {
storeStewardToken();
const { ports: p } = ports();
const outcome = await listOrAutoProvisionCloudAgent(draft(), p);
expect(outcome.kind).toBe("done");
// The user/status probe is OFF the chain — the agents list is the probe.
expect(clientStub.getCloudStatus).not.toHaveBeenCalled();
// Exactly one list fetch, no duplicate in the bind.
expect(clientStub.getCloudCompatAgents).toHaveBeenCalledTimes(1);
expect(clientStub.selectOrProvisionCloudAgent).toHaveBeenCalledTimes(1);
expect(
clientStub.selectOrProvisionCloudAgent.mock.calls[0][0].knownAgents,
).toEqual([RUNNING_AGENT]);
});

it("stale bearer degrade: a failed list falls back to the status probe and re-enters login instead of stranding", async () => {
storeStewardToken("stale-jwt");
clientStub.getCloudCompatAgents
.mockResolvedValueOnce({ success: false, data: [], error: "401" })
.mockResolvedValueOnce({ success: true, data: [RUNNING_AGENT] });
const { ports: p, handleCloudLogin } = ports();
const outcome = await listOrAutoProvisionCloudAgent(draft(), p);
expect(outcome.kind).toBe("done");
// The failure path consulted the status probe (legacy semantics kept)…
expect(clientStub.getCloudStatus).toHaveBeenCalled();
// …and re-entered login rather than treating the dead list as connected.
expect(handleCloudLogin).toHaveBeenCalledTimes(1);
expect(clientStub.getCloudCompatAgents).toHaveBeenCalledTimes(2);
});

it("after handleCloudLogin lands a bearer there is NO post-login status re-probe — one probe total on the no-token entry", async () => {
const { ports: p, handleCloudLogin } = ports();
handleCloudLogin.mockImplementation(async () => {
storeStewardToken("fresh-jwt");
});
const outcome = await listOrAutoProvisionCloudAgent(draft(), p);
expect(outcome.kind).toBe("done");
expect(handleCloudLogin).toHaveBeenCalledTimes(1);
// Exactly ONE status probe (the pre-login connectivity check). The old
// code issued a second one after login whose result was overridden by the
// token check anyway — that serial round trip must not come back.
expect(clientStub.getCloudStatus).toHaveBeenCalledTimes(1);
expect(clientStub.getCloudCompatAgents).toHaveBeenCalledTimes(1);
});

it("returns needs-cloud-login when login lands no token and the probe stays disconnected", async () => {
const { ports: p, handleCloudLogin } = ports();
const outcome = await listOrAutoProvisionCloudAgent(draft(), p);
expect(outcome.kind).toBe("needs-cloud-login");
expect(handleCloudLogin).toHaveBeenCalledTimes(1);
// Pre-login probe + post-login probe (no token landed, so the probe is
// still the only evidence available).
expect(clientStub.getCloudStatus).toHaveBeenCalledTimes(2);
expect(clientStub.getCloudCompatAgents).not.toHaveBeenCalled();
});
});

describe("bindCloudAgent — agent-base warm-up", () => {
it("fires a fire-and-forget conversations fetch on the just-bound base so the post-ready hydrate hits a warm container", async () => {
const outcome = await bindCloudAgent(
draft(),
"steward-token",
{},
ports().ports,
);
expect(outcome.kind).toBe("done");
expect(clientStub.setBaseUrl).toHaveBeenCalledWith(SHARED_AGENT_BASE);
expect(clientStub.listConversations).toHaveBeenCalledTimes(1);
});

it("a hanging or rejecting warm-up never blocks or fails the bind", async () => {
clientStub.listConversations = vi.fn(
() => Promise.reject(new Error("cold container")) as never,
);
const outcome = await bindCloudAgent(
draft(),
"steward-token",
{},
ports().ports,
);
expect(outcome.kind).toBe("done");
});

it("a client shim without the chat surface is a safe no-op", async () => {
clientStub.listConversations = undefined;
const outcome = await bindCloudAgent(
draft(),
"steward-token",
{},
ports().ports,
);
expect(outcome.kind).toBe("done");
});
});
83 changes: 70 additions & 13 deletions packages/ui/src/first-run/first-run-finish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,22 @@ export async function bindCloudAgent(
}
client.setBaseUrl(cloudAgentApiBase);
client.setToken(authToken);
// Warm the agent base NOW, overlapping everything between here and the
// hydrate phase's real conversation fetch (persist, coordinator phase
// transitions, the auth gate's /api/auth/me — measured 2.3s cold on
// staging, FIRSTLOAD-REAL-2026-07-22). The first request to a cold cloud
// container pays connection setup + worker/container wake; issuing a
// throwaway list here means the post-ready /api/conversations hits a warm
// path instead of serializing the full cold cost behind auth/me.
// Fire-and-forget: the result is discarded and failures are irrelevant —
// the hydrate call remains the authoritative fetch. Optional-chained so
// chat-surface-less client shims (tests, legacy) are a no-op; the
// Promise.resolve().then wrapper absorbs a synchronous throw from a
// non-conforming shim without an empty catch block.
// error-policy:J6 best-effort warm-up — failure is fully degradable.
void Promise.resolve()
.then(() => client.listConversations?.())
.catch(() => undefined);
const activeServer = createPersistedActiveServer({
kind: "cloud",
id: `cloud:${selectedAgent.agentId}`,
Expand Down Expand Up @@ -762,27 +778,65 @@ export async function listOrAutoProvisionCloudAgent(
firstRunRuntimeTarget("cloud"),
);
ports.setRuntimeState("firstRunProvider", "elizacloud");
// One shared list fetch shape: never throws — a rejected lookup collapses to
// the same `success:false` the error path below renders (the throw-through
// behavior is unchanged for callers via that path).
const listAgents = () =>
client.getCloudCompatAgents().catch((cause: unknown) => ({
success: false as const,
data: [] as CloudCompatAgent[],
error: cause instanceof Error ? cause.message : undefined,
}));
let agentsList: Awaited<ReturnType<typeof listAgents>> | null = null;
let cloudConnectedForFinish = ports.elizaCloudConnected;
if (!cloudConnectedForFinish) {
const cloudStatus = await getCloudStatusIfSupported();
cloudConnectedForFinish = isCloudStatusAuthenticated(
Boolean(cloudStatus?.connected),
cloudStatus?.reason,
);
if (getCloudAuthToken(client)) {
// A stored bearer makes the agents list itself the authoritative
// connectivity probe — and the list is the data the bind step needs
// anyway. Fetch it NOW instead of serializing a /api/v1/user status
// round trip before it: on staging the old user->agents chain was
// ~2.6s of the measured post-token first-load stall
// (FIRSTLOAD-REAL-2026-07-22). The status probe runs only when the
// list fails (stale/revoked token), preserving the legacy login
// re-entry semantics on that path.
const early = await listAgents();
if (early.success) {
agentsList = early;
cloudConnectedForFinish = true;
} else {
const cloudStatus = await getCloudStatusIfSupported();
cloudConnectedForFinish = isCloudStatusAuthenticated(
Boolean(cloudStatus?.connected),
cloudStatus?.reason,
);
}
} else {
const cloudStatus = await getCloudStatusIfSupported();
cloudConnectedForFinish = isCloudStatusAuthenticated(
Boolean(cloudStatus?.connected),
cloudStatus?.reason,
);
}
}
if (
firstRunNeedsCloudConnect(sourceDraft, cloudConnectedForFinish) ||
!getCloudAuthToken(client)
) {
const authWindow = ports.preOpenWindow?.() ?? null;
await ports.handleCloudLogin(authWindow, { requireClientAuth: true });
const cloudStatus = await getCloudStatusIfSupported();
cloudConnectedForFinish = isCloudStatusAuthenticated(
Boolean(cloudStatus?.connected),
cloudStatus?.reason,
);
if (!cloudConnectedForFinish && getCloudAuthToken(client)) {
// A landed bearer IS the proof every following step runs on — the old
// post-login status re-probe's result was overridden by exactly this
// token check, so the extra /api/v1/user round trip (~0.8s on staging)
// bought nothing. Probe only when no token landed (server-side-key
// logins), where the probe result still decides the outcome.
if (getCloudAuthToken(client)) {
cloudConnectedForFinish = true;
} else {
const cloudStatus = await getCloudStatusIfSupported();
cloudConnectedForFinish = isCloudStatusAuthenticated(
Boolean(cloudStatus?.connected),
cloudStatus?.reason,
);
}
if (!cloudConnectedForFinish) {
return { kind: "needs-cloud-login" };
Expand All @@ -792,8 +846,11 @@ export async function listOrAutoProvisionCloudAgent(
if (!authToken) {
return { kind: "needs-cloud-login" };
}
ports.onStatus?.("Finding your agents...", "listing");
const list = await client.getCloudCompatAgents();
let list = agentsList;
if (!list) {
ports.onStatus?.("Finding your agents...", "listing");
list = await listAgents();
}
if (!list.success) {
return {
kind: "error",
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/first-run/use-first-run-conductor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,10 @@ describe("useFirstRunConductor", () => {
const authWindow = { close: vi.fn() } as unknown as Window;
mocks.preOpenCloudLoginWindow.mockReturnValue(authWindow);
mocks.client.getCloudStatus.mockResolvedValue({ connected: false });
// No stored bearer: a usable stored token now short-circuits login
// entirely (the agents list is the connectivity probe), so the popup
// path this test pins is only reachable when login is genuinely needed.
localStorage.removeItem("steward_session_token");
const spies = seedAppStore({ elizaCloudConnected: false });
const { turn, unmount } = renderConductor();
await waitForTurn(turn, "first-run:greeting");
Expand Down
Loading