Skip to main content

Agents

📘 Doc-testing – Why do these examples look like tests?

This documentation uses testable code examples to ensure accuracy and reliability:

  • Guaranteed accuracy: All examples are real, working code that runs against the actual library
  • Always up-to-date: When the library changes, the tests fail and the docs must be updated
  • Copy-paste confidence: What you see is what works - no outdated or broken examples
  • Real-world patterns: Tests show complete, runnable scenarios, not just snippets

Ignore the test boilerplate (it(), describe(), etc.) - focus on the code inside.

This document demonstrates testing your use of Cloudflare's Agent class and AgentClient (both from the agents package) using @lumenize/testing. We show two scenarios:

  1. Multi-user chat - Testing state synchronization across multiple WebSocket connections
  2. Advanced authentication - Using Cloudflare KV for session storage, token smuggling via WebSocket protocols, and RPC access to verify authentication state

For basic usage of @lumenize/testing, see the usage documentation.

Why testing an Agent is hard​

To test your Agent implementation, you have a few options.

You could stand up two separate processes: one to host your Worker and Agent DO, and another to run AgentClient and have them talk over localhost to each other, but that's unecessary friction, especially in CI; it doesn't give you unified test coverage metrics, and is less conducive to fast iteration by both people and AI coding agents. It also doesn't allow you to manipulate or inspect your Agent's state from your test except through your Agent's public API. In other words, no runInDurableObject capabilities, which brings us to...

You can avoid the multi-process approach by using cloudflare:test's runInDurableObject to exercise your Agent DO, but you are calling handlers directly, which bypasses the Worker routing, input/output gates, your DO's own fetch, etc. This lower fidelity can allow subtle bugs to escape to production like happened with agents.

On the other hand, cloudflare:test also provides SELF.fetch(). It runs through your Worker, DO fetch, respects input/output gates, etc. Yes, it's HTTP-only, but there is a little trick you can use to do some web socket testing. You can send in an HTTP Request with the correct upgrade headers, and extract the raw ws object out of the Response. Then use ws.send() to send messages to your Agent's onMessage handler. Some of the agents package tests now do exactly this. However, this raw ws object is not a full WebSocket instance, and even if it were, classes like AgentClient expect to instantiate the WebSocket themselves. Without AgentClient, you are stuck recreating, simulating, or mocking built-in functionality like state synchronization.

How @lumenize/testing makes this better​

@lumenize/testing uses the same raw ws trick as the newer agents tests, except it wraps it in a browser-compatible WebSocket API class. AgentClient allows you to dependency inject your own WebSocket class, as we show below. Now we are getting somewhere.

Add @lumenize/testing's createTestingClient's RPC capability, and you now have the same ability as runInDurableObject to prepopulate and inspect your Agent's state at any point during the test... all through one clean API.

Benefits​

This gives you a number of advantages:

  • Test Agents with AgentClient or any other client-side library
  • Use the browser WebSocket API
  • No need to stand up a separate "server" to run tests against
    • CI friendly
    • Super fast, local dev/AI coding cycles
    • Unified test coverage
    • Unified stack trace when you encounter an error
  • As all things Lumenize, de✨light✨ful DX
    • A fraction of the boilerplate
    • Well tested
    • Well documented
    • Examples guaranteed in sync with code via doc-testing
    • Conveniences like cookie sharing between HTTP and WebSocket handshake like a real browser, realistic CORS behavior, etc.
    • Assert on under-the-covers behavior like the request/response from/to AgentClient during the WebSocket upgrade handshake, HttpOnly cookies, etc.

Multi-user chat example​

This example demonstrates:

  • Creating multiple users with separate Browser instances—provides cookie isolation, which is not crucial in this case but is good practice and is critical for the test that follows
  • Using AgentClient with injected WebSocket to connect to the same DO instance
  • Testing state synchronization across WebSocket connections
  • Accessing DO instance variables via RPC (lastMessage)
  • Verifying DO storage persistence (totalMessageCount)
import { it, expect, vi } from 'vitest';
import type { RpcAccessible } from '@lumenize/testing';
import { createTestingClient, Browser } from '@lumenize/testing';
import { AgentClient } from 'agents/client';
import { ChatAgent, AuthAgent } from '../src';

type ChatAgentType = RpcAccessible<InstanceType<typeof ChatAgent>>;
type AuthAgentType = RpcAccessible<InstanceType<typeof AuthAgent>>;

it('shows testing two users in a chat', async () => {
// Create RPC client with binding name and instance name
await using client = createTestingClient<ChatAgentType>('chat-agent', 'chat');

// Check initial value of instance variable lastMessage
expect(await client.lastMessage).toBeNull();

// Track latest state for both clients
let aliceState: any = null;
let bobState: any = null;

// Create Alice's browser and agent client
const aliceWebSocket = new Browser().WebSocket;
const aliceClient = new AgentClient({
host: 'example.com',
agent: 'chat-agent',
name: 'chat',
WebSocket: aliceWebSocket, // AgentClient let's us inject aliceWebSocket!
onStateUpdate: (state) => {
aliceState = state;
},
});

aliceClient.onopen = () => {
aliceClient.send(JSON.stringify({ type: 'join', username: 'Alice' }));
};

// Create Bob's browser and agent client
const bobBrowser = new Browser();
const bobClient = new AgentClient({
host: 'example.com',
agent: 'chat-agent',
name: 'chat',
WebSocket: bobBrowser.WebSocket,
onStateUpdate: (state) => {
bobState = state;
},
});

bobClient.onopen = () => {
bobClient.send(JSON.stringify({ type: 'join', username: 'Bob' }));
};

// Wait to see that they've both joined
await vi.waitFor(() => {
expect(bobState.participants).toContain('Bob');
expect(bobState.participants).toContain('Alice');
expect(aliceState.participants).toContain('Bob');
expect(aliceState.participants).toContain('Alice');
});

// Alice sends a chat message
aliceClient.send(
JSON.stringify({ type: 'chat', username: 'Alice', text: 'Hello Bob!' })
);

// Wait for message to appear in state
await vi.waitFor(() => {
expect(aliceState.messages.length).toBeGreaterThan(0);
});

// Verify both users see the message
expect(aliceState.messages[0].sender).toBe('Alice');
expect(aliceState.messages[0].text).toBe('Hello Bob!');

// Verify Bob also received the message
expect(bobState.messages[0].text).toBe('Hello Bob!');

// Verify that lastMessage instance variable is as expected
expect(await client.lastMessage).toBeInstanceOf(Date);

// Verify that storage persists total message count
const totalCount = await client.ctx.storage.kv.get('totalMessageCount');
expect(totalCount).toBe(1);
});

Follow the instructions below to setup testing for your own agents.

Installation​

First let's install some tools.

npm install --save-dev vitest@3.2
npm install --save-dev @vitest/coverage-istanbul@3.2
npm install --save-dev @cloudflare/vitest-pool-workers
npm install --save-dev @lumenize/testing
npm install --save-dev @lumenize/utils

Setup files​

src/index.ts​

The Worker below is used by the test above as well as the one down below.

It exports a fetch handler that provides a /login endpoint for authentication. This endpoint generates a session ID and token, stores the mapping in Cloudflare KV, sets an HttpOnly cookie, and returns the token to the client in the response body.

Two Agent classes are defined:

  • ChatAgent: Used by the test above—handles chat messages with join/chat events, tracks lastMessage instance variable, and persists totalMessageCount in storage
  • AuthAgent: Used by the test below—validates authentication tokens from WebSocket protocol headers against KV session storage, closing connections with code 1008 if invalid
import { Agent, Connection, ConnectionContext, WSMessage } from "agents";

// The `routeAgentRequest` from @lumenize/utils is near a drop in replacement
// but with upgrades. We say "near" because the signature for the hooks
// `onBeforeRequest` and `onBeforeConnect` are different. Better, but different.
// The advantages include much better documentation, better testing, and CORS
// allowlist support (critical for WebSocket usage, although not shown below).
import { routeAgentRequest } from "@lumenize/utils";

// Worker
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);

// Handle login endpoint
if (url.pathname === '/login') {
const password = url.searchParams.get('password');
if (!password) {
return new Response('Password required', { status: 400 });
}

// Confirm password - not shown

// Generate session ID and token
const sessionId = crypto.randomUUID();
const token = crypto.randomUUID();

// Store session -> token mapping in KV
await env.SESSION_STORE.put(sessionId, token);

// Set cookie and return token in body
return new Response(JSON.stringify({ token }), {
headers: {
'Content-Type': 'application/json',
'Set-Cookie': `sessionId=${sessionId}; Path=/; HttpOnly; SameSite=Strict`
}
});
}

return (
await routeAgentRequest(request, env) ||
new Response("Not Found", { status: 404 })
);
}
} satisfies ExportedHandler<Env>;

interface ChatState {
messages: Array<{ sender: string; text: string; }>;
participants: string[];
}

// Agent
export class ChatAgent extends Agent<Env, ChatState>{
initialState = {
messages: [],
participants: [],
};

lastMessage: Date | null = null;

onMessage(connection: Connection, message: WSMessage) {
const msg = JSON.parse(message as string);

this.lastMessage = new Date();

if (msg.type === 'join') {
// Add participant to state
this.setState({
...this.state,
participants: [...this.state.participants, msg.username],
});
} else if (msg.type === 'chat') {
// Increment total message count in storage
const count = this.ctx.storage.kv.get<number>('totalMessageCount') ?? 0;
this.ctx.storage.kv.put('totalMessageCount', count + 1);

// Add chat message to state
this.setState({
...this.state,
messages: [...this.state.messages, {
sender: msg.username,
text: msg.text,
}],
});
}
}
};

// AuthAgent - demonstrates authentication with token validation
export class AuthAgent extends Agent<Env, {}> {
async onConnect(connection: Connection, ctx: ConnectionContext) {
// Extract token from WebSocket protocol (second protocol in the array)
const protocols = ctx.request.headers.get('Sec-WebSocket-Protocol');
const token = protocols?.split(',')
.map(p => p.trim()).find(p => p.startsWith('auth.'))?.slice(5);

// Extract sessionId from cookie
const cookieHeader = ctx.request.headers.get('Cookie');
const sessionId = cookieHeader?.split(';')
.map(c => c.trim()).find(c => c.startsWith('sessionId='))?.slice(10);

if (!token || !sessionId) {
return connection.close(1008, 'Missing authentication credentials');
}

// Validate token matches sessionId in KV
const storedToken = await this.env.SESSION_STORE.get(sessionId);
if (storedToken !== token) {
return connection.close(1008, 'Invalid authentication token');
}

// Authentication successful - echo back the sessionId
// In a real app, you might store session info in the DO or do other setup
connection.send(JSON.stringify({
type: 'auth_success',
sessionId,
message: 'Authentication successful'
}));
}
}

Here are the remainder of the setup files for this example.

test/test-harness.ts​

The test harness uses instrumentDOProject with explicit doClassNames configuration to export both Agent classes for testing.

import * as sourceModule from '../src';
import { instrumentDOProject } from '@lumenize/testing';

// Specify which exports are Durable Objects
const instrumented = instrumentDOProject({
sourceModule,
doClassNames: ['ChatAgent', 'AuthAgent']
});

export const { ChatAgent, AuthAgent } = instrumented.dos;
export default instrumented;

test/wrangler.jsonc​

Test configuration includes:

  • SESSION_STORE KV namespace binding for session storage
  • CHAT_AGENT and AUTH_AGENT Durable Object bindings
  • Migrations to enable both Agent classes in the test environment
{
"name": "testing-agent-with-agent-client",
"main": "./test-harness.ts", // The only difference from real wrangler.jsonc
"compatibility_date": "2025-09-12",
"compatibility_flags": [
"nodejs_compat"
],
"migrations": [
{
"new_sqlite_classes": [
"ChatAgent"
],
"tag": "v1"
},
{
"new_sqlite_classes": [
"AuthAgent"
],
"tag": "v2"
}
],
"durable_objects": {
"bindings": [
{
"class_name": "ChatAgent",
"name": "CHAT_AGENT"
},
{
"class_name": "AuthAgent",
"name": "AUTH_AGENT"
}
]
},
"kv_namespaces": [
{
"binding": "SESSION_STORE",
"id": "preview_id"
}
]
}

vitest.config.js​

Standard vitest configuration for Cloudflare Workers testing with:

  • isolatedStorage: false required for WebSocket support
  • globals: true for global test functions
  • Coverage configured with Istanbul provider
import { defineWorkersProject } from "@cloudflare/vitest-pool-workers/config";

export default defineWorkersProject({
test: {
deps: {
optimizer: {
ssr: {
include: [
// vitest can't seem to properly import
// `require('./path/to/anything.json')` files,
// which ajv uses (by way of @modelcontextprotocol/sdk)
// the workaround is to add the package to the include list
"ajv"
]
}
}
},
testTimeout: 2000, // 2 second global timeout
poolOptions: {
workers: {
isolatedStorage: false, // Must be false for now to use websockets. Have each test create a new DO instance to avoid state sharing.
wrangler: { configPath: "./test/wrangler.jsonc" }, // Important! use the wrangler.jsonc in ./test
},
},
coverage: {
provider: "istanbul",
reporter: ['text', 'json', 'html'],
include: ['**/src/**'],
exclude: [
'**/node_modules/**',
'**/dist/**',
'**/build/**',
'**/*.config.ts',
'**/scratch/**'
],
},
},
});

Advanced authentication example​

This example demonstrates a complete authentication flow using:

  1. Session management: Worker /login endpoint generates sessionId + token, stores in KV
  2. Token smuggling: Client passes token via WebSocket protocols array as auth.${token}
  3. Cookie-based session: HttpOnly cookie contains sessionId for validation
  4. DO validation: AuthAgent.onConnect extracts token and sessionId, validates via KV
  5. RPC verification: Test uses RPC client to directly inspect KV storage state
  6. Error handling: Wrong token closes connection with code 1008
  7. Success flow: Valid token sends auth_success message with sessionId

Key testing patterns:

  • Use Browser for realistic cookie/fetch behavior
  • Access client.env.SESSION_STORE via RPC to verify server-side state
  • Test both failure (wrong token) and success (correct token) paths
  • Use vi.waitFor() for async WebSocket events
it('demonstrates advanced authentication with KV session storage', async () => {
// Create RPC client for AuthAgent to access its internals
await using client = createTestingClient<AuthAgentType>('auth-agent', 'auth');

// Create a browser for making the login request
const browser = new Browser();

// Login to get token and sessionId cookie
const loginResponse = await browser.fetch(
'http://example.com/login?password=secret'
);
expect(loginResponse.status).toBe(200);

const loginData = await loginResponse.json() as { token: string };
const { token } = loginData;
expect(token).toBeDefined();

// Verify cookie was set
// Note: In a real browser, HttpOnly cookies cannot be read by JavaScript.
// Browser.getCookie() is a testing convenience that lets us inspect cookies
// that would otherwise be inaccessible to client code.
const sessionId = browser.getCookie('sessionId', 'example.com');
expect(sessionId).toBeDefined();

// Verify the session was actually stored in KV (via RPC client)
const storedToken = await client.env.SESSION_STORE.get(sessionId!);
expect(storedToken).toBe(token);

// Attempt connection with WRONG token (should fail)
const wrongToken = 'wrong-token-' + crypto.randomUUID();
let closeCode = 0;
let closeReason = '';

const wrongTokenClient = new AgentClient({
host: 'example.com',
agent: 'auth-agent',
name: 'auth',
WebSocket: browser.WebSocket,
protocols: ['real.protocol', `auth.${wrongToken}`], // smuggle in token
});

wrongTokenClient.addEventListener('close', (event) => {
closeCode = event.code;
closeReason = event.reason;
});

// Wait for connection to be rejected
await vi.waitFor(() => {
expect(closeCode).toBe(1008);
expect(closeReason).toBe('Invalid authentication token');
});

// Connect with CORRECT token (should succeed)
let authMessage: any = null;
const correctClient = new AgentClient({
host: 'example.com',
agent: 'auth-agent',
name: 'auth',
WebSocket: browser.WebSocket,
protocols: ['real.protocol', `auth.${token}`], // smuggle in token
});

correctClient.addEventListener('message', (event) => {
authMessage = JSON.parse(event.data as string);
});

// Wait for successful auth message and verify it
await vi.waitFor(() => {
expect(authMessage?.type).toBe('auth_success');
expect(authMessage?.sessionId).toBe(sessionId);
expect(authMessage?.message).toBe('Authentication successful');
});
});

Try it out​

To run it as a vitest:

vitest --run

You can even see how much of the code is covered by these tests. With the correct vitest config, this will even include your client code:

vitest --run --coverage