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:
- Multi-user chat - Testing state synchronization across multiple WebSocket connections
- 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 injectedWebSocket
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, trackslastMessage
instance variable, and persiststotalMessageCount
in storageAuthAgent
: 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 storageCHAT_AGENT
andAUTH_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 supportglobals: 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:
- Session management: Worker
/login
endpoint generates sessionId + token, stores in KV - Token smuggling: Client passes token via WebSocket
protocols
array asauth.${token}
- Cookie-based session: HttpOnly cookie contains sessionId for validation
- DO validation:
AuthAgent.onConnect
extracts token and sessionId, validates via KV - RPC verification: Test uses RPC client to directly inspect KV storage state
- Error handling: Wrong token closes connection with code 1008
- 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