feat(chat): add connecting status during organization bootstrap

This commit is contained in:
-Puter
2026-07-23 16:54:15 +05:30
parent 5d5139dc95
commit 5b0bf35175
16 changed files with 581 additions and 137 deletions

View File

@@ -40,6 +40,7 @@
"tailwindcss": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:",
"vite-tsconfig-paths": "^6.1.1"
}
}

View File

@@ -11,7 +11,8 @@ import { useChatComposer } from "@/hooks/chat/use-chat-composer";
import type { ChatComposerProps } from "@/lib/chat/types";
export const ChatComposer = ({ error, onSend, status }: ChatComposerProps) => {
const busy = status === "submitted" || status === "streaming";
const busy =
status === "connecting" || status === "submitted" || status === "streaming";
const composer = useChatComposer({ busy, onSend });
let statusContent: ReactNode = (
@@ -23,7 +24,7 @@ export const ChatComposer = ({ error, onSend, status }: ChatComposerProps) => {
statusContent = (
<>
<LoaderCircle className="size-3.5 animate-spin" />
Zopu is responding
{status === "connecting" ? "Preparing Zopu" : "Zopu is responding"}
</>
);
}

View File

@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { useChatAgent } from "./use-chat-agent";
const mocks = vi.hoisted(() => ({
agent: {
error: undefined,
historyReady: true,
messages: [],
sendMessage: vi.fn(() => Promise.resolve()),
status: "idle" as const,
},
organization: {} as { error?: Error; organizationId?: string },
useFlueAgent: vi.fn(),
}));
vi.mock("@flue/react", () => ({
useFlueAgent: mocks.useFlueAgent,
}));
vi.mock("@/hooks/use-personal-organization", () => ({
usePersonalOrganization: () => mocks.organization,
}));
describe("useChatAgent", () => {
beforeEach(() => {
mocks.organization = {};
mocks.agent.sendMessage.mockClear();
mocks.useFlueAgent.mockReset();
mocks.useFlueAgent.mockReturnValue(mocks.agent);
});
test("does not use the agent before organization bootstrap completes", async () => {
const chat = useChatAgent();
expect(mocks.useFlueAgent).toHaveBeenCalledWith({
id: undefined,
live: "sse",
name: "zopu",
});
expect(chat.status).toBe("connecting");
await expect(chat.sendMessage("too early")).rejects.toThrow(
"Personal organization is still being prepared"
);
expect(mocks.agent.sendMessage).not.toHaveBeenCalled();
});
test("uses the ensured organization id for the agent session", async () => {
mocks.organization = { organizationId: "org-a" };
const chat = useChatAgent();
expect(mocks.useFlueAgent).toHaveBeenCalledWith({
id: "org-a",
live: "sse",
name: "zopu",
});
await chat.sendMessage("ready");
expect(mocks.agent.sendMessage).toHaveBeenCalledWith("ready");
});
});

View File

@@ -1,36 +1,38 @@
import { api } from "@code/backend/convex/_generated/api";
import { useFlueAgent } from "@flue/react";
import { useQuery } from "convex/react";
import { usePersonalOrganization } from "@/hooks/use-personal-organization";
import { CHAT_AGENT } from "@/lib/chat/constants";
import type { ChatAgentState } from "@/lib/chat/types";
import { setSendRequestId } from "@/root";
export const useChatAgent = (): ChatAgentState => {
// The global Zopu agent instance id is the user's current personal
// organization id. This is the hard tenancy boundary: the server rejects an
// instance id that is not the caller's own organization.
const organization = useQuery(api.organizations.getCurrent);
// The agent session is not created until the authenticated user's personal
// organization has been ensured and its id is available.
const organization = usePersonalOrganization();
const agent = useFlueAgent({
...CHAT_AGENT,
id: organization?._id,
id: organization.organizationId,
});
const sendMessage = async (message: string): Promise<void> => {
// Generate a stable idempotency request id once per send, immediately
// before the message is submitted. It is reused across fetch/stream
// retries for this send and reset only on the next send, so the server
// can capture normalized evidence keyed by (organization, request id)
// without creating duplicates.
setSendRequestId(crypto.randomUUID());
if (!organization.organizationId) {
throw (
organization.error ??
new Error("Personal organization is still being prepared")
);
}
await agent.sendMessage(message);
};
let { status } = agent;
if (!organization.organizationId) {
status = organization.error ? "error" : "connecting";
}
return {
error: agent.error,
error: organization.error ?? agent.error,
historyReady: agent.historyReady,
messages: agent.messages,
sendMessage,
status: agent.status,
status,
};
};

View File

@@ -0,0 +1,77 @@
import type { EffectCallback } from "react";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { usePersonalOrganization } from "./use-personal-organization";
const mocks = vi.hoisted(() => ({
cleanup: undefined as (() => void) | undefined,
ensurePersonalOrganization: vi.fn(),
session: {
data: { session: { id: "session-a" } },
} as { data: { session: { id: string } } | null },
state: null as {
error?: Error;
organizationId?: string;
sessionId: string;
} | null,
}));
vi.mock("@code/auth/web", () => ({
authClient: { useSession: () => mocks.session },
}));
vi.mock("@code/backend/convex/_generated/api", () => ({
api: { organizations: { ensurePersonalOrganization: "ensure-org" } },
}));
vi.mock("convex/react", () => ({
useMutation: () => mocks.ensurePersonalOrganization,
}));
vi.mock("react", () => ({
useEffect: (effect: EffectCallback) => {
mocks.cleanup?.();
const cleanup = effect();
mocks.cleanup = typeof cleanup === "function" ? cleanup : undefined;
},
useState: () => [
mocks.state,
(next: { error?: Error; organizationId?: string; sessionId: string }) => {
mocks.state = next;
},
],
}));
describe("usePersonalOrganization", () => {
beforeEach(() => {
mocks.cleanup?.();
mocks.cleanup = undefined;
mocks.ensurePersonalOrganization.mockReset();
mocks.session = { data: { session: { id: "session-a" } } };
mocks.state = null;
});
test("an authenticated session ensures its org before exposing the id", async () => {
const ensured = Promise.withResolvers<{ _id: string }>();
mocks.ensurePersonalOrganization.mockReturnValue(ensured.promise);
expect(usePersonalOrganization()).toEqual({});
expect(mocks.ensurePersonalOrganization).toHaveBeenCalledTimes(1);
ensured.resolve({ _id: "org-a" });
await ensured.promise;
await Promise.resolve();
expect(usePersonalOrganization()).toEqual({
error: undefined,
organizationId: "org-a",
});
});
test("logout cannot expose the prior session organization", async () => {
mocks.ensurePersonalOrganization.mockResolvedValue({ _id: "org-a" });
usePersonalOrganization();
await Promise.resolve();
await Promise.resolve();
mocks.session = { data: null };
expect(usePersonalOrganization()).toEqual({});
});
});

View File

@@ -0,0 +1,70 @@
import { authClient } from "@code/auth/web";
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useMutation } from "convex/react";
import { useEffect, useState } from "react";
interface OrganizationBootstrapState {
readonly error?: Error;
readonly organizationId?: Id<"organizations">;
readonly sessionId: string;
}
export interface PersonalOrganizationState {
readonly error?: Error;
readonly organizationId?: Id<"organizations">;
}
/** Ensure the authenticated session has its personal tenancy boundary. */
export const usePersonalOrganization = (): PersonalOrganizationState => {
const { data: session } = authClient.useSession();
const sessionId = session?.session.id;
const ensurePersonalOrganization = useMutation(
api.organizations.ensurePersonalOrganization
);
const [bootstrap, setBootstrap] = useState<OrganizationBootstrapState | null>(
null
);
useEffect(() => {
if (!sessionId) {
return;
}
let active = true;
const bootstrapOrganization = async (): Promise<void> => {
try {
const organization = await ensurePersonalOrganization();
if (active) {
setBootstrap({
organizationId: organization._id,
sessionId,
});
}
} catch (caughtError: unknown) {
if (active) {
setBootstrap({
error:
caughtError instanceof Error
? caughtError
: new Error(String(caughtError)),
sessionId,
});
}
}
};
void bootstrapOrganization();
return () => {
active = false;
};
}, [ensurePersonalOrganization, sessionId]);
if (!sessionId || bootstrap?.sessionId !== sessionId) {
return {};
}
return {
error: bootstrap.error,
organizationId: bootstrap.organizationId,
};
};

View File

@@ -1,14 +1,14 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { useFlueClient } from "@flue/react";
import { useAction, useMutation, useQuery } from "convex/react";
import { useState } from "react";
import { flueClient } from "@/root";
const errorMessage = (error: unknown) =>
error instanceof Error ? error.message : String(error);
export const useProjectWorkspace = () => {
const flueClient = useFlueClient();
const projects = useQuery(api.projects.list);
const [selectedProjectId, setSelectedProjectId] =
useState<Id<"projects"> | null>(null);

View File

@@ -0,0 +1,115 @@
import { createFlueClient } from "@flue/sdk";
import { describe, expect, test } from "vitest";
import { createFlueFetch } from "./flue-transport";
const BASE_URL = new URL("https://flue.example/api/");
describe("createFlueFetch", () => {
test("overlapping SDK agent sends keep distinct request IDs", async () => {
const firstResponse = Promise.withResolvers<Response>();
const secondResponse = Promise.withResolvers<Response>();
const bothStarted = Promise.withResolvers<boolean>();
const capturedHeaders: Headers[] = [];
let requestCount = 0;
let requestId = 0;
const fetchImpl: typeof fetch = (input, init) => {
capturedHeaders.push(new Headers(init?.headers));
requestCount += 1;
if (requestCount === 2) {
bothStarted.resolve(true);
}
if (requestCount === 1) {
return firstResponse.promise;
}
if (requestCount === 2) {
return secondResponse.promise;
}
throw new Error(`Unexpected request: ${String(input)}`);
};
const client = createFlueClient({
baseUrl: BASE_URL.toString(),
fetch: createFlueFetch({
baseUrl: BASE_URL,
fetchImpl,
generateRequestId: () => `request-${(requestId += 1)}`,
}),
headers: { authorization: "Bearer current-jwt" },
});
const firstSend = client.agents.send("zopu", "org-a", {
message: "first",
});
const secondSend = client.agents.send("zopu", "org-a", {
message: "second",
});
await bothStarted.promise;
expect(capturedHeaders).toHaveLength(2);
expect(capturedHeaders[0]?.get("x-zopu-request-id")).toBe("request-1");
expect(capturedHeaders[1]?.get("x-zopu-request-id")).toBe("request-2");
expect(capturedHeaders[0]?.get("authorization")).toBe("Bearer current-jwt");
expect(capturedHeaders[1]?.get("authorization")).toBe("Bearer current-jwt");
firstResponse.resolve(
Response.json(
{
offset: "1",
streamUrl: "http://internal/streams/first",
submissionId: "submission-1",
},
{ status: 202 }
)
);
secondResponse.resolve(
Response.json(
{
offset: "2",
streamUrl: "http://internal/streams/second",
submissionId: "submission-2",
},
{ status: 202 }
)
);
await expect(Promise.all([firstSend, secondSend])).resolves.toEqual([
{
offset: "1",
streamUrl: "https://flue.example/streams/first",
submissionId: "submission-1",
},
{
offset: "2",
streamUrl: "https://flue.example/streams/second",
submissionId: "submission-2",
},
]);
});
test("history and stream observation requests remain untagged", async () => {
const capturedHeaders: Headers[] = [];
const fetchImpl: typeof fetch = (_input, init) => {
capturedHeaders.push(new Headers(init?.headers));
return Promise.resolve(new Response(null, { status: 204 }));
};
const flueFetch = createFlueFetch({
baseUrl: BASE_URL,
fetchImpl,
generateRequestId: () => "must-not-be-used",
});
await flueFetch("https://flue.example/api/agents/zopu/org-a?view=history", {
headers: { authorization: "Bearer current-jwt" },
method: "GET",
});
await flueFetch("https://flue.example/api/agents/zopu/org-a?view=updates", {
headers: { authorization: "Bearer current-jwt" },
method: "GET",
});
expect(capturedHeaders).toHaveLength(2);
expect(capturedHeaders[0]?.has("x-zopu-request-id")).toBe(false);
expect(capturedHeaders[1]?.has("x-zopu-request-id")).toBe(false);
});
});

View File

@@ -0,0 +1,82 @@
interface FlueFetchOptions {
readonly baseUrl: URL;
readonly fetchImpl?: typeof fetch;
readonly generateRequestId?: () => string;
}
/**
* Build the Flue transport used by the browser client.
*
* The installed Flue SDK does not expose per-call headers on `agents.send`.
* Its custom `fetch` seam does receive the resolved URL and method, so request
* IDs are attached here to each agent admission POST. History and Durable
* Streams observation requests are GETs and intentionally remain untagged.
*/
export const createFlueFetch = ({
baseUrl,
fetchImpl = fetch,
generateRequestId = crypto.randomUUID,
}: FlueFetchOptions): typeof fetch => {
const basePath = baseUrl.pathname.replace(/\/+$/u, "");
return async (input, init) => {
const inputUrl =
typeof input === "string" || input instanceof URL ? input : input.url;
const requestUrl = new URL(inputUrl, baseUrl);
const method = (
init?.method ?? (input instanceof Request ? input.method : "GET")
).toUpperCase();
const relativePath = requestUrl.pathname.startsWith(`${basePath}/`)
? requestUrl.pathname.slice(basePath.length)
: requestUrl.pathname;
const pathSegments = relativePath.split("/").filter(Boolean);
const isAgentAdmission =
method === "POST" &&
pathSegments.length === 3 &&
pathSegments[0] === "agents";
let requestInit = init;
if (isAgentAdmission) {
const headers = new Headers(
input instanceof Request ? input.headers : undefined
);
for (const [key, value] of new Headers(init?.headers).entries()) {
headers.set(key, value);
}
headers.set("x-zopu-request-id", generateRequestId());
requestInit = { ...init, headers };
}
const response = await fetchImpl(input, requestInit);
if (
!response.ok ||
!response.headers.get("content-type")?.includes("application/json")
) {
return response;
}
const body = (await response.clone().json()) as unknown;
if (
typeof body !== "object" ||
body === null ||
!("streamUrl" in body) ||
typeof body.streamUrl !== "string"
) {
return response;
}
const streamUrl = new URL(body.streamUrl);
streamUrl.protocol = baseUrl.protocol;
streamUrl.host = baseUrl.host;
const headers = new Headers(response.headers);
headers.set("location", streamUrl.toString());
return Response.json(
{ ...body, streamUrl: streamUrl.toString() },
{
headers,
status: response.status,
statusText: response.statusText,
}
);
};
};

View File

@@ -1,10 +1,11 @@
import { authClient, WebAuthProvider } from "@code/auth/web";
import { useConvexAccessToken, WebAuthProvider } from "@code/auth/web";
import { env } from "@code/env/web";
import { Toaster } from "@code/ui/components/sonner";
import { FlueProvider } from "@flue/react";
import "./index.css";
import { createFlueClient } from "@flue/sdk";
import { useMemo } from "react";
import {
isRouteErrorResponse,
Links,
@@ -16,6 +17,7 @@ import {
import type { Route } from "./+types/root";
import { ThemeProvider } from "./components/theme-provider";
import { createFlueFetch } from "./lib/flue-transport";
export const links: Route.LinksFunction = () => [
{ href: "https://fonts.googleapis.com", rel: "preconnect" },
@@ -31,87 +33,33 @@ export const links: Route.LinksFunction = () => [
];
const flueBaseUrl = new URL(env.VITE_FLUE_URL);
const flueFetch = createFlueFetch({ baseUrl: flueBaseUrl });
// ---------------------------------------------------------------------------
// Authenticated Flue request headers.
//
// The access token is the Better Auth/Convex JWT, resolved once per session
// through the installed `convex.token` plugin endpoint and cached. The stable
// per-send request id is generated by the chat hook immediately before a user
// sends a message and is reused across fetch/stream retries for that send, so
// it is a reliable idempotency key for server-side evidence capture. It is
// reset only when a new message is sent.
// ---------------------------------------------------------------------------
let cachedAccessToken: string | null = null;
let currentSendRequestId: string | null = null;
/** Set the idempotency request id for the next send. Called by the chat hook. */
export const setSendRequestId = (id: string): void => {
currentSendRequestId = id;
};
const resolveAccessToken = async (): Promise<string | null> => {
if (cachedAccessToken) {
return cachedAccessToken;
}
const { data } = await authClient.convex.token({
fetchOptions: { throw: false },
});
cachedAccessToken = data?.token ?? null;
return cachedAccessToken;
};
const resolveFlueHeaders = async (): Promise<Record<string, string>> => {
const headers: Record<string, string> = {};
const accessToken = await resolveAccessToken();
if (accessToken) {
headers.authorization = `Bearer ${accessToken}`;
}
if (currentSendRequestId) {
headers["x-zopu-request-id"] = currentSendRequestId;
}
return headers;
};
const flueFetch: typeof fetch = async (input, init) => {
const response = await fetch(input, init);
if (
!response.ok ||
!response.headers.get("content-type")?.includes("application/json")
) {
return response;
}
const body = (await response.clone().json()) as unknown;
if (
typeof body !== "object" ||
body === null ||
!("streamUrl" in body) ||
typeof body.streamUrl !== "string"
) {
return response;
}
const streamUrl = new URL(body.streamUrl);
streamUrl.protocol = flueBaseUrl.protocol;
streamUrl.host = flueBaseUrl.host;
const headers = new Headers(response.headers);
headers.set("location", streamUrl.toString());
return Response.json(
{ ...body, streamUrl: streamUrl.toString() },
{
headers,
status: response.status,
statusText: response.statusText,
}
const AuthenticatedFlueProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const resolveAccessToken = useConvexAccessToken();
const client = useMemo(
() =>
createFlueClient({
baseUrl: flueBaseUrl.toString(),
fetch: flueFetch,
headers: async () => {
const accessToken = await resolveAccessToken();
const headers: Record<string, string> = {};
if (accessToken) {
headers.authorization = `Bearer ${accessToken}`;
}
return headers;
},
}),
[resolveAccessToken]
);
return <FlueProvider client={client}>{children}</FlueProvider>;
};
export const flueClient = createFlueClient({
baseUrl: flueBaseUrl.toString(),
fetch: flueFetch,
headers: resolveFlueHeaders,
});
export const Layout = ({ children }: { children: React.ReactNode }) => (
<html lang="en">
<head>
@@ -129,8 +77,8 @@ export const Layout = ({ children }: { children: React.ReactNode }) => (
);
const App = () => (
<FlueProvider client={flueClient}>
<WebAuthProvider>
<WebAuthProvider>
<AuthenticatedFlueProvider>
<ThemeProvider
attribute="class"
defaultTheme="dark"
@@ -142,8 +90,8 @@ const App = () => (
</div>
<Toaster richColors />
</ThemeProvider>
</WebAuthProvider>
</FlueProvider>
</AuthenticatedFlueProvider>
</WebAuthProvider>
);
export default App;

View File

@@ -1,7 +1,7 @@
{
"include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"lib": ["DOM", "DOM.Iterable", "ES2024"],
"types": ["node", "vite/client"],
"target": "ES2022",
"module": "ES2022",