diff --git a/packages/app/src/agents/site-builder.test.ts b/packages/app/src/agents/site-builder.test.ts index 59d832b..0e978cf 100644 --- a/packages/app/src/agents/site-builder.test.ts +++ b/packages/app/src/agents/site-builder.test.ts @@ -187,8 +187,11 @@ describe("project context", () => { expect(context).toContain("assets/poster.png"); expect(context).toContain("inspect_image"); expect(context).toContain("read_url"); - expect(context).not.toContain("Uploaded documents in the project: assets/legacy.docx"); - expect(context).not.toContain("supported documents"); + // Only PDFs are extractable; an unsupported upload must not be offered to + // extract_document_text. + const extractionGuidance = context.split("\n").find((line) => line.includes("extract_document_text")); + expect(extractionGuidance).toContain("assets/cv.pdf"); + expect(extractionGuidance).not.toContain("legacy.docx"); }); }); diff --git a/packages/app/src/boundary-logging.test.ts b/packages/app/src/boundary-logging.test.ts index a45288f..4e32ce7 100644 --- a/packages/app/src/boundary-logging.test.ts +++ b/packages/app/src/boundary-logging.test.ts @@ -23,7 +23,6 @@ import { emitDiagnostic, errorCodeFrom, mintCorrelation, - outcomeForStatus, principalForOperationalSubject, terminalForStatus, withCorrelationFetch, @@ -477,7 +476,7 @@ describe("service-local diagnostics and helpers", () => { }); it("maps terminal outcomes and principals without treating legacy ids as subjects", () => { - expect(outcomeForStatus(200)).toBe("ok"); + expect(terminalForStatus(200).outcome).toBe("ok"); expect(terminalForStatus(401)).toEqual({ outcome: "denied", reason: "denied" }); expect(terminalForStatus(429)).toEqual({ outcome: "denied", reason: "rate_limited" }); expect(terminalForStatus(503)).toEqual({ outcome: "error", reason: "application_failure" }); diff --git a/packages/app/src/fleet-projection.test.ts b/packages/app/src/fleet-projection.test.ts index 028d97f..aa7cc9e 100644 --- a/packages/app/src/fleet-projection.test.ts +++ b/packages/app/src/fleet-projection.test.ts @@ -3,7 +3,6 @@ import { CAIL_ANALYTICS_ENGINE_BLOBS, CAIL_ANALYTICS_ENGINE_DOUBLES, CAIL_EVENTS, - ROUTE_TEMPLATE_RE, type CailAnalyticsEngineDataPoint, } from "@cuny-ai-lab/cail-log"; import { OBSERVABILITY_CONTRACT } from "./lib/observability/contract"; @@ -82,14 +81,4 @@ describe("CAIL fleet projection boundary", () => { consoleLog.mockRestore(); } }); - - it("recognizes every canonical action route and keeps build/publish distinct", () => { - expect(Object.entries(OBSERVABILITY_CONTRACT.actions)).toEqual([ - ["build", { route: "/api/agents/site-builder/{project_id}", method: "POST" }], - ["publish", { route: "/api/projects/{id}/publish", method: "POST" }], - ]); - for (const action of Object.values(OBSERVABILITY_CONTRACT.actions)) { - expect(ROUTE_TEMPLATE_RE.test(action.route)).toBe(true); - } - }); }); diff --git a/packages/app/src/lib/cail-identity.test.ts b/packages/app/src/lib/cail-identity.test.ts index d2118e8..57b9705 100644 --- a/packages/app/src/lib/cail-identity.test.ts +++ b/packages/app/src/lib/cail-identity.test.ts @@ -9,7 +9,6 @@ import { } from "@cuny-ai-lab/cail-identity/testing"; import { cailAuthRequiredResponse, - getRequestIdentity, resolveRequestIdentity, } from "./cail-identity"; @@ -131,57 +130,64 @@ beforeAll(async () => { }; }); -describe("getRequestIdentity", () => { +const INVALID = { status: "invalid" } as const; + +describe("resolveRequestIdentity token verification", () => { it("accepts a valid canonical identity token", async () => { - const identity = await getRequestIdentity(requestWithToken(await mintJwt()), currentEnv); + const token = await mintJwt(); + const result = await resolveRequestIdentity(requestWithToken(token), currentEnv); - expect(identity).toEqual({ - subject: TEST_SUBJECTS.alice, - email: "someone@gc.cuny.edu", - name: "Some One", - entitlements: ["site-studio"], + expect(result).toEqual({ + status: "verified", + token, + identity: { + subject: TEST_SUBJECTS.alice, + email: "someone@gc.cuny.edu", + name: "Some One", + entitlements: ["site-studio"], + }, }); }); it("rejects a token signed by a key outside the configured JWKS", async () => { const otherIssuer = await createTestIdentityIssuer({ kid: "other" }); const token = await otherIssuer.mintIdentityJwt({ audience: AUDIENCE }); - expect(await getRequestIdentity(requestWithToken(token), currentEnv)).toBeNull(); + expect(await resolveRequestIdentity(requestWithToken(token), currentEnv)).toEqual(INVALID); }); it("rejects an algorithm other than RS256", async () => { // Sanity: the same local key verifies when the header is untampered. - expect(await getRequestIdentity(requestWithToken(await signLocalJwt(localKey)), localEnv)) - .not.toBeNull(); + expect(await resolveRequestIdentity(requestWithToken(await signLocalJwt(localKey)), localEnv)) + .toMatchObject({ status: "verified" }); const token = await signLocalJwt(localKey, {}, { alg: "none", typ: "JWT", kid: localKey.jwk.kid, }); - expect(await getRequestIdentity(requestWithToken(token), localEnv)).toBeNull(); + expect(await resolveRequestIdentity(requestWithToken(token), localEnv)).toEqual(INVALID); }); it("rejects an expired token", async () => { // exp = (now - 3720) + 3600 = 120 seconds in the past. const token = await mintJwt({ now: Math.floor(Date.now() / 1000) - 3720 }); - expect(await getRequestIdentity(requestWithToken(token), currentEnv)).toBeNull(); + expect(await resolveRequestIdentity(requestWithToken(token), currentEnv)).toEqual(INVALID); }); it("rejects the wrong audience", async () => { const token = await mintJwt({ audience: "cail:another-service" }); - expect(await getRequestIdentity(requestWithToken(token), currentEnv)).toBeNull(); + expect(await resolveRequestIdentity(requestWithToken(token), currentEnv)).toEqual(INVALID); }); it("rejects array-valued audiences, including a one-element array", async () => { const token = await mintJwt({ audience: [AUDIENCE] }); - expect(await getRequestIdentity(requestWithToken(token), currentEnv)).toBeNull(); + expect(await resolveRequestIdentity(requestWithToken(token), currentEnv)).toEqual(INVALID); }); it("preserves the verified canonical subject byte-for-byte as the durable owner key", async () => { const subject = canonicalTestSubject("durable-owner"); const token = await mintJwt({ subject }); - await expect(getRequestIdentity(requestWithToken(token), currentEnv)) - .resolves.toMatchObject({ subject }); + await expect(resolveRequestIdentity(requestWithToken(token), currentEnv)) + .resolves.toMatchObject({ status: "verified", identity: { subject } }); }); it("rejects non-canonical subjects (v4 accepts only cail-<32 lowercase hex>)", async () => { @@ -192,7 +198,7 @@ describe("getRequestIdentity", () => { "someone@gc.cuny.edu", ]) { const token = await mintJwt({ subject }); - expect(await getRequestIdentity(requestWithToken(token), currentEnv)).toBeNull(); + expect(await resolveRequestIdentity(requestWithToken(token), currentEnv)).toEqual(INVALID); } }); @@ -200,8 +206,8 @@ describe("getRequestIdentity", () => { const untrusted = await mintJwt({ issuer: "https://evil.example/not-cail" }); const lookAlike = await mintJwt({ issuer: "https://evil.example/cail-sso" }); - expect(await getRequestIdentity(requestWithToken(untrusted), currentEnv)).toBeNull(); - expect(await getRequestIdentity(requestWithToken(lookAlike), currentEnv)).toBeNull(); + expect(await resolveRequestIdentity(requestWithToken(untrusted), currentEnv)).toEqual(INVALID); + expect(await resolveRequestIdentity(requestWithToken(lookAlike), currentEnv)).toEqual(INVALID); }); it("rejects a self-consistent attacker issuer, JWKS, and token configuration", async () => { @@ -212,10 +218,10 @@ describe("getRequestIdentity", () => { issuer: attackerIssuer, }); - await expect(getRequestIdentity(requestWithToken(token), { + await expect(resolveRequestIdentity(requestWithToken(token), { CAIL_IDENTITY_JWKS: attacker.jwksJson, CAIL_IDENTITY_ISSUER: attackerIssuer, - })).resolves.toBeNull(); + })).resolves.toEqual(INVALID); }); it("fails closed when the configured issuer is not CAIL's canonical issuer", async () => { @@ -225,19 +231,19 @@ describe("getRequestIdentity", () => { { ...currentEnv, CAIL_IDENTITY_ISSUER: undefined }, { ...currentEnv, CAIL_IDENTITY_ISSUER: "https://evil.example/cail-sso" }, ]) { - await expect(getRequestIdentity(requestWithToken(productionToken), env)) - .resolves.toBeNull(); + await expect(resolveRequestIdentity(requestWithToken(productionToken), env)) + .resolves.toEqual(INVALID); } }); it("rejects an empty subject", async () => { const token = await mintJwt({ subject: "" }); - expect(await getRequestIdentity(requestWithToken(token), currentEnv)).toBeNull(); + expect(await resolveRequestIdentity(requestWithToken(token), currentEnv)).toEqual(INVALID); }); it("rejects malformed tokens", async () => { - expect(await getRequestIdentity(requestWithToken("not-a-jwt"), currentEnv)).toBeNull(); - expect(await getRequestIdentity(requestWithToken("a.b"), currentEnv)).toBeNull(); + expect(await resolveRequestIdentity(requestWithToken("not-a-jwt"), currentEnv)).toEqual(INVALID); + expect(await resolveRequestIdentity(requestWithToken("a.b"), currentEnv)).toEqual(INVALID); }); }); diff --git a/packages/app/src/lib/cail-identity.ts b/packages/app/src/lib/cail-identity.ts index 2d28854..7af16fb 100644 --- a/packages/app/src/lib/cail-identity.ts +++ b/packages/app/src/lib/cail-identity.ts @@ -103,19 +103,6 @@ export async function resolveRequestIdentity( : { status: "invalid" }; } -/** - * Verify the selected CAIL identity carried on a request, if any. Returns the - * identity or `null`; callers that must distinguish invalid credentials from absence - * use `resolveRequestIdentity`. Never throws. - */ -export async function getRequestIdentity( - request: Request, - env: IdentityVerificationEnv -): Promise { - const resolution = await resolveRequestIdentity(request, env); - return resolution.status === "verified" ? resolution.identity : null; -} - /** * The CAIL `authentication_required` envelope (docs/security-and-recovery.md, * identity and ownership), returned verbatim so the frontend treats worker-issued diff --git a/packages/app/src/lib/document.test.ts b/packages/app/src/lib/document.test.ts index 1f07f0b..261063a 100644 --- a/packages/app/src/lib/document.test.ts +++ b/packages/app/src/lib/document.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { extractDocumentText, supportsDocumentExtraction, type DocumentExtractor } from "./document"; +import { extractDocumentText, type DocumentExtractor } from "./document"; describe("document extraction", () => { const mockedExtractText = vi.fn(); @@ -12,11 +12,6 @@ describe("document extraction", () => { mockedGetMeta.mockReset(); }); - it("marks PDFs as supported for extraction", () => { - expect(supportsDocumentExtraction("paper.pdf")).toBe(true); - expect(supportsDocumentExtraction("notes.md")).toBe(false); - }); - it("extracts text and metadata from PDFs", async () => { mockedExtractText.mockResolvedValue({ totalPages: 2, diff --git a/packages/app/src/lib/document.ts b/packages/app/src/lib/document.ts index fa915c9..2084aca 100644 --- a/packages/app/src/lib/document.ts +++ b/packages/app/src/lib/document.ts @@ -69,10 +69,6 @@ function formatPdfPages(pages: string[]): FormattedPdfPages { return { text, warnings }; } -export function supportsDocumentExtraction(filePath: string): boolean { - return getContentType(filePath) === "application/pdf"; -} - export async function extractDocumentText( filePath: string, data: Uint8Array, diff --git a/packages/app/src/lib/http.test.ts b/packages/app/src/lib/http.test.ts deleted file mode 100644 index 45d61ee..0000000 --- a/packages/app/src/lib/http.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { jsonError, jsonHeaders, binaryBody, readFormData } from "./http"; - -describe("jsonError", () => { - it.each([undefined, 404] as const)("preserves the status and message for %s", (status) => { - expect(() => jsonError("Request failed", status)).toThrowError( - expect.objectContaining({ status: status ?? 400, message: "Request failed" }) - ); - }); -}); - -describe("readFormData", () => { - it("preserves the uploaded file name and bytes", async () => { - const bytes = new Uint8Array([0, 17, 128, 255]); - const body = new FormData(); - body.append("file", new File([bytes], "example.png")); - const parsed = await readFormData(new Request("https://site-studio.test/upload", { method: "POST", body })); - const file = parsed.get("file"); - if (!(file instanceof File)) throw new Error("Uploaded file is missing"); - expect(file.name).toBe("example.png"); - expect(new Uint8Array(await file.arrayBuffer())).toEqual(bytes); - }); - - it("returns a client error for malformed form framing", async () => { - const request = new Request("https://site-studio.test/upload", { - method: "POST", - headers: { "content-type": "multipart/form-data; boundary=missing" }, - body: "invalid framing", - }); - await expect(readFormData(request)).rejects.toMatchObject({ status: 400, message: "Invalid multipart form data" }); - }); -}); - -describe("jsonHeaders", () => { - it("sets Content-Type to application/json by default", () => { - const headers = jsonHeaders(); - expect(headers.get("Content-Type")).toBe("application/json; charset=utf-8"); - }); - - it("does not overwrite existing Content-Type", () => { - const headers = jsonHeaders({ "Content-Type": "text/plain" }); - expect(headers.get("Content-Type")).toBe("text/plain"); - }); - - it("preserves extra headers", () => { - const headers = jsonHeaders({ "X-Custom": "value" }); - expect(headers.get("X-Custom")).toBe("value"); - expect(headers.get("Content-Type")).toBe("application/json; charset=utf-8"); - }); -}); - -describe("binaryBody", () => { - it("preserves binary response bytes", async () => { - const data = new Uint8Array([1, 2, 3]); - const blob = binaryBody(data); - expect(new Uint8Array(await blob.arrayBuffer())).toEqual(data); - }); - - it("returns empty Blob for empty input", () => { - const blob = binaryBody(new Uint8Array([])); - expect(blob.size).toBe(0); - }); -}); diff --git a/packages/app/src/lib/http.ts b/packages/app/src/lib/http.ts index 26a19a0..54300bd 100644 --- a/packages/app/src/lib/http.ts +++ b/packages/app/src/lib/http.ts @@ -16,14 +16,6 @@ export async function readFormData(request: Request): Promise { } } -export function jsonHeaders(extra?: HeadersInit): Headers { - const headers = new Headers(extra); - if (!headers.has("Content-Type")) { - headers.set("Content-Type", "application/json; charset=utf-8"); - } - return headers; -} - export function binaryBody(data: Uint8Array): Blob { return new Blob([new Uint8Array(data)]); } diff --git a/packages/app/src/lib/logging.ts b/packages/app/src/lib/logging.ts index dd05ebe..f8b95c1 100644 --- a/packages/app/src/lib/logging.ts +++ b/packages/app/src/lib/logging.ts @@ -352,10 +352,6 @@ export function terminalForStatus(status: number): CailTerminalFields { return { outcome: "ok", reason: "completed" }; } -export function outcomeForStatus(status: number): CailTerminalFields["outcome"] { - return terminalForStatus(status).outcome; -} - /** Stable machine type derived only from the exception class, never its message. */ export function errorCodeFrom(cause: unknown): string { const name = cause instanceof Error && cause.name ? cause.name : "error"; diff --git a/packages/app/src/lib/session.test.ts b/packages/app/src/lib/session.test.ts index ceacb83..f63ce4a 100644 --- a/packages/app/src/lib/session.test.ts +++ b/packages/app/src/lib/session.test.ts @@ -8,7 +8,7 @@ import { type TestIdentityIssuer, } from "@cuny-ai-lab/cail-identity/testing"; import type { Env } from "../types"; -import { authMiddleware, getCailIdentityJwt } from "./session"; +import { authMiddleware, getCailGatewayJwt } from "./session"; import { migrateAnonymousData } from "./migration"; import type { MigrationResult } from "./migration"; import { @@ -43,6 +43,15 @@ function mintIdentityJwt(sub: string): Promise { }); } +function mintGatewayJwt(sub: string): Promise { + return identityIssuer.mintIdentityJwt({ + audience: "cail:gateway", + subject: sub, + email: "u@gc.cuny.edu", + expiresInSeconds: 300 + }); +} + /** * In-memory stand-in for the MigrationCoordinator DO namespace. vitest runs * under node and cannot load the real `cloudflare:workers` DO class, so we model @@ -373,29 +382,53 @@ describe("authMiddleware", () => { expect(response.status).toBe(401); }); - it("stores the verified canonical token for downstream calls", async () => { + it("stores a verified same-subject gateway leg for forwarding", async () => { const app = new Hono<{ Bindings: Env; - Variables: { user: { id: string; createdAt: string }; cailIdentityJwt?: string }; + Variables: { user: { id: string; createdAt: string }; cailGatewayJwt?: string }; }>(); app.use("*", authMiddleware); - app.get("/api/test", (c) => c.json({ user: c.get("user"), forwardedToken: getCailIdentityJwt(c) })); + app.get("/api/test", (c) => c.json({ user: c.get("user"), gatewayToken: getCailGatewayJwt(c) })); const subject = TEST_SUBJECTS.bob; - const token = await mintIdentityJwt(subject); + const gatewayToken = await mintGatewayJwt(subject); const response = await app.request( "http://site-studio.test/api/test", - { headers: { "X-CAIL-Identity-JWT": token } }, + { + headers: { + "X-CAIL-Identity-JWT": await mintIdentityJwt(subject), + "X-CAIL-Gateway-Identity-JWT": gatewayToken, + }, + }, createEnv({ CAIL_IDENTITY_JWKS: identityJwks }) ); expect(response.status).toBe(200); await expect(response.json()).resolves.toMatchObject({ user: { id: subject }, - forwardedToken: token + gatewayToken }); }); + it("rejects a gateway leg minted for a different subject", async () => { + const app = new Hono<{ Bindings: Env; Variables: { user: { id: string; createdAt: string } } }>(); + app.use("*", authMiddleware); + app.get("/api/test", (c) => c.json({ user: c.get("user") })); + + const response = await app.request( + "http://site-studio.test/api/test", + { + headers: { + "X-CAIL-Identity-JWT": await mintIdentityJwt(TEST_SUBJECTS.bob), + "X-CAIL-Gateway-Identity-JWT": await mintGatewayJwt(TEST_SUBJECTS.alice), + }, + }, + createEnv({ CAIL_IDENTITY_JWKS: identityJwks }) + ); + + expect(response.status).toBe(401); + }); + it("ignores a bare X-CAIL-Subject header and requires verified identity", async () => { const app = new Hono<{ Bindings: Env; Variables: { user: { id: string; createdAt: string } } }>(); app.use("*", authMiddleware); diff --git a/packages/app/src/lib/session.ts b/packages/app/src/lib/session.ts index 438cbe3..6b8ff6f 100644 --- a/packages/app/src/lib/session.ts +++ b/packages/app/src/lib/session.ts @@ -24,10 +24,9 @@ export type SessionVariables = { sessionId: string; user: User; /** - * Raw, already-verified selected identity JWT from the current request. - * Downstream handlers forward this exact token to the CAIL model proxy. + * Verified keyring gateway leg (cail:gateway audience, same subject as the + * app leg). This is the only identity token forwarded to the CAIL Gateway. */ - cailIdentityJwt?: string; cailGatewayJwt?: string; }; @@ -148,8 +147,7 @@ export const authMiddleware = createMiddleware<{ if (identityResolution.status === "verified") { // Verified CAIL identity: own everything by the subject. - const { identity, token } = identityResolution; - c.set("cailIdentityJwt", token); + const { identity } = identityResolution; // Keyring gateway leg (identity-keyring-v1): verified against the // gateway audience and bound to this request's subject before it may be @@ -198,11 +196,6 @@ export const authMiddleware = createMiddleware<{ return cailAuthRequiredResponse(); }); -/** The verified CAIL identity JWT for this request, if any (already verified). */ -export function getCailIdentityJwt(c: { get: (key: "cailIdentityJwt") => string | undefined }): string | null { - return c.get("cailIdentityJwt") ?? null; -} - /** The verified keyring gateway leg for this request, if delivered. */ export function getCailGatewayJwt(c: { get: (key: "cailGatewayJwt") => string | undefined }): string | null { return c.get("cailGatewayJwt") ?? null; diff --git a/packages/app/src/observability-contract.test.ts b/packages/app/src/observability-contract.test.ts index 3b97769..7e81987 100644 --- a/packages/app/src/observability-contract.test.ts +++ b/packages/app/src/observability-contract.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { ROUTE_TEMPLATE_RE } from "@cuny-ai-lab/cail-log"; import { OBSERVABILITY_CONTRACT, OBSERVABILITY_CONTRACT_VERSION, @@ -36,6 +37,9 @@ describe("observability contract", () => { method: "POST", }, }); + for (const action of Object.values(OBSERVABILITY_CONTRACT.actions)) { + expect(ROUTE_TEMPLATE_RE.test(action.route)).toBe(true); + } expect(OBSERVABILITY_CONTRACT.telemetryQuality.actionPair.joinKey).toBe("cail.action.id"); }); diff --git a/packages/app/src/routes/agents.ts b/packages/app/src/routes/agents.ts index 39ac380..5e5a46b 100644 --- a/packages/app/src/routes/agents.ts +++ b/packages/app/src/routes/agents.ts @@ -17,7 +17,7 @@ import { } from "../lib/logging"; import { outboundCorrelationHeaders } from "@cuny-ai-lab/cail-log"; -type AgentRouterVariables = LoggingVariables & { user: { id: string }; cailIdentityJwt?: string }; +type AgentRouterVariables = LoggingVariables & { user: { id: string } }; type AgentRouterStub = { fetch(request: Request): Promise; diff --git a/packages/app/src/types.ts b/packages/app/src/types.ts index a1cf30d..366fcc1 100644 --- a/packages/app/src/types.ts +++ b/packages/app/src/types.ts @@ -6,11 +6,6 @@ import type { CailLogEnvironment, } from "@cuny-ai-lab/cail-log"; -export function parsePositiveInteger(value: string | undefined): number | null { - const parsed = Number(value); - return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null; -} - export interface Env { // Cloudflare Version Metadata binding. It is unavailable in local/test // runtimes, so the health response treats it as optional. diff --git a/packages/frontend/src/lib/components/AgentChat.test.ts b/packages/frontend/src/lib/components/AgentChat.test.ts index 13ec6ed..5cdb43d 100644 --- a/packages/frontend/src/lib/components/AgentChat.test.ts +++ b/packages/frontend/src/lib/components/AgentChat.test.ts @@ -2316,6 +2316,7 @@ describe('AgentChat', () => { // An unexpected close schedules reconnect attempt #1. first.serverClose(); flushSync(); + expect(FakeWebSocket.instances.length).toBe(1); await vi.advanceTimersByTimeAsync(1000); flushSync(); await vi.advanceTimersByTimeAsync(0); @@ -2648,32 +2649,6 @@ describe('AgentChat', () => { }); await waitFor(() => expect(screen.getByText('fresh response')).toBeInTheDocument()); }); - - it('schedules a reconnect with backoff after an unexpected socket close', async () => { - // Let the initial connection happen under real timers (it awaits the - // history fetch, a real promise), then switch to fake timers to drive the - // reconnect backoff deterministically. - mount(); - await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1)); - const ws = FakeWebSocket.instances[0]; - ws.open(); - await settle(); - - vi.useFakeTimers(); - try { - // Unexpected close schedules a reconnect; no new socket yet. - ws.serverClose(); - flushSync(); - expect(FakeWebSocket.instances.length).toBe(1); - - // First backoff is 1000ms. - await vi.advanceTimersByTimeAsync(1000); - flushSync(); - expect(FakeWebSocket.instances.length).toBe(2); - } finally { - vi.useRealTimers(); - } - }); }); // Helper to render AgentChat and get a handle on the component instance so we